mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0d655d08a | |||
| 99f8488f78 | |||
| 884c256033 | |||
| 78235385dd | |||
| fd213e6df6 | |||
| 3726052307 | |||
| 7d33a6f7c9 | |||
| 7a035d7fc0 | |||
| 9151af7045 | |||
| 15bcbb1d7a | |||
| 4d3294727c | |||
| aae0e89519 | |||
| 93a7b4ab76 | |||
| 0ebe74b625 | |||
| b9dae8593c | |||
| d8f9388610 | |||
| be14739cce | |||
| 762588c251 | |||
| d6e54e9042 | |||
| d4fd528152 | |||
| de185559dd | |||
| bc5ce5eab1 | |||
| b10d617c80 | |||
| 18466b8020 | |||
| 71ec022b47 | |||
| f7eeb08942 | |||
| 9205dfe724 | |||
| a3776429aa | |||
| c0e43c0c65 | |||
| 08c5a2a5e8 | |||
| 7077c70d60 | |||
| 1fd8bf526d | |||
| 6d9539f469 | |||
| e5101d9651 | |||
| b0151e1d02 | |||
| c3637753bc | |||
| 48fc9e3cc3 | |||
| 82a482b36d | |||
| 7fac84319d | |||
| 0a5e617da8 | |||
| b5f92c9f48 | |||
| 2070fd9bc2 | |||
| 48dc05eee7 | |||
| 84bb706537 | |||
| be8cfa7e08 | |||
| 56a789d926 | |||
| 5409151ad4 | |||
| f90d154465 | |||
| 078385d386 | |||
| beaaa174ea | |||
| fb59606bb4 | |||
| 846d548154 | |||
| 84336e4f91 | |||
| 01a5c69244 | |||
| d78f91afeb | |||
| 33762292f7 | |||
| 5d6aa3b41a | |||
| 683aca5dbe | |||
| 92025e9a47 | |||
| 411e053572 | |||
| b862d178bf | |||
| bda0ddc207 | |||
| 58ba99e505 | |||
| 6ee817d041 | |||
| dfeb1b5051 | |||
| 61a7f6db35 | |||
| 6446b8ae3b | |||
| ae53163cad | |||
| 41202819a4 | |||
| a31698f99b | |||
| 6248542c49 | |||
| d25c91e5eb | |||
| 5d63020dcd | |||
| 062f54590e | |||
| a76c6918d2 | |||
| 2b91a6f210 | |||
| 10579cceb2 | |||
| 25702e01ce | |||
| ecc5c44d9a | |||
| f5a0b920a2 | |||
| 2caa016fe1 | |||
| 6861fedd09 | |||
| 3d072112ce | |||
| bdfea046db | |||
| a2d08fb63b | |||
| 5a55135d89 | |||
| 8870d36e0f | |||
| eb923c27ca | |||
| 9903abc704 | |||
| 225a1fbf35 | |||
| 93159bccbf | |||
| fab8ec4f54 | |||
| a3035c53ea | |||
| 36c416e143 | |||
| e1e0304a96 | |||
| 71c3a7c8f2 | |||
| ecdfff5a42 | |||
| 4b948c5d74 | |||
| cd56c51e2d | |||
| 43e39d7f68 | |||
| 7a17925495 | |||
| 5acb2530b4 | |||
| 44a6787359 | |||
| 42e6b7db32 | |||
| 2c02f8bace | |||
| 2ec20e576b | |||
| 20f47fec7a | |||
| 65210f2d97 | |||
| af0b7ffae7 |
@@ -34,11 +34,25 @@ jobs:
|
|||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const twoHours = 2 * 60 * 60 * 1000;
|
const twoHours = 2 * 60 * 60 * 1000;
|
||||||
|
const orgMemberAssociations = new Set(['OWNER', 'MEMBER']);
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const isPR = !!item.pull_request;
|
const isPR = !!item.pull_request;
|
||||||
const kind = isPR ? 'PR' : 'issue';
|
const kind = isPR ? 'PR' : 'issue';
|
||||||
|
|
||||||
|
if (orgMemberAssociations.has(item.author_association)) {
|
||||||
|
core.info(`Skipping ${kind} #${item.number}; author association is ${item.author_association}`);
|
||||||
|
try {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: item.number,
|
||||||
|
name: 'needs:compliance',
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const { data: comments } = await github.rest.issues.listComments({
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ jobs:
|
|||||||
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
|
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
|
||||||
|
|
||||||
Issue number: ${{ github.event.issue.number }}
|
Issue number: ${{ github.event.issue.number }}
|
||||||
|
Issue author association: ${{ github.event.issue.author_association }}
|
||||||
|
|
||||||
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||||
|
|
||||||
@@ -49,6 +50,8 @@ jobs:
|
|||||||
|
|
||||||
Check whether the issue follows our contributing guidelines and issue templates.
|
Check whether the issue follows our contributing guidelines and issue templates.
|
||||||
|
|
||||||
|
If the issue author association is OWNER or MEMBER, skip this compliance check. Do not add the needs:compliance label for organization-owned issues.
|
||||||
|
|
||||||
This project has three issue templates that every issue MUST use one of:
|
This project has three issue templates that every issue MUST use one of:
|
||||||
|
|
||||||
1. Bug Report - requires a Description field with real content
|
1. Bug Report - requires a Description field with real content
|
||||||
@@ -83,7 +86,7 @@ jobs:
|
|||||||
|
|
||||||
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
|
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
|
||||||
|
|
||||||
If the issue is NOT compliant, start the comment with:
|
If the issue is NOT compliant and the author association is not OWNER or MEMBER, start the comment with:
|
||||||
<!-- issue-compliance -->
|
<!-- issue-compliance -->
|
||||||
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
|
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
|
||||||
|
|
||||||
@@ -148,9 +151,12 @@ jobs:
|
|||||||
}
|
}
|
||||||
run: |
|
run: |
|
||||||
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
|
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
|
||||||
|
Issue author association: ${{ github.event.issue.author_association }}
|
||||||
|
|
||||||
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||||
|
|
||||||
|
If the issue author association is OWNER or MEMBER, remove the needs:compliance label if present, delete the previous compliance comment if present, and do not post a new comment.
|
||||||
|
|
||||||
Re-check whether the issue now follows our contributing guidelines and issue templates.
|
Re-check whether the issue now follows our contributing guidelines and issue templates.
|
||||||
|
|
||||||
This project has three issue templates that every issue MUST use one of:
|
This project has three issue templates that every issue MUST use one of:
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- ci
|
- ci
|
||||||
- dev
|
- dev
|
||||||
- v2
|
|
||||||
- beta
|
- beta
|
||||||
- fix/npm-native-binary-install
|
- fix/npm-native-binary-install
|
||||||
- snapshot-*
|
- snapshot-*
|
||||||
@@ -32,9 +31,6 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
env:
|
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
version:
|
version:
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
@@ -126,7 +122,7 @@ jobs:
|
|||||||
- build-cli
|
- build-cli
|
||||||
- version
|
- version
|
||||||
runs-on: blacksmith-4vcpu-windows-2025
|
runs-on: blacksmith-4vcpu-windows-2025
|
||||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
if: github.repository == 'anomalyco/opencode'
|
||||||
env:
|
env:
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
@@ -225,7 +221,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build-cli
|
- build-cli
|
||||||
- version
|
- version
|
||||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
if: github.repository == 'anomalyco/opencode'
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
env:
|
env:
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
@@ -451,7 +447,6 @@ jobs:
|
|||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
if: github.ref_name != 'v2'
|
|
||||||
with:
|
with:
|
||||||
name: opencode-cli-signed-windows
|
name: opencode-cli-signed-windows
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
- v2
|
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -75,9 +74,13 @@ jobs:
|
|||||||
working-directory: packages/client
|
working-directory: packages/client
|
||||||
run: bun run check:generated
|
run: bun run check:generated
|
||||||
|
|
||||||
|
- name: Run HttpApi exerciser gates
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
working-directory: packages/opencode
|
||||||
|
run: bun run test:httpapi
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: e2e (${{ matrix.settings.name }})
|
name: e2e (${{ matrix.settings.name }})
|
||||||
if: github.ref_name != 'v2' && github.head_ref != 'v2'
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
export default {
|
|
||||||
id: "sample-agent-plugin",
|
|
||||||
setup: async (ctx) => {
|
|
||||||
await ctx.agent.transform((agents) => {
|
|
||||||
agents.update("sample-plugin-agent", (agent) => {
|
|
||||||
agent.description = "Example subagent registered by .opencode/plugins/sample-agent.ts"
|
|
||||||
agent.mode = "subagent"
|
|
||||||
agent.prompt = [
|
|
||||||
"You are the sample plugin agent for this repository.",
|
|
||||||
"Use this agent to verify that local plugin auto-discovery can add agents.",
|
|
||||||
"Keep responses concise and explain which plugin registered you when asked.",
|
|
||||||
].join("\n")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
|
||||||
- The default branch in this repo is `dev`.
|
- The default branch in this repo is `dev`.
|
||||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||||
|
|
||||||
|
|||||||
@@ -94,11 +94,10 @@
|
|||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.17.11",
|
"version": "1.17.11",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode2": "./bin/opencode2.cjs",
|
"lildax": "./bin/lildax.cjs",
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@opencode-ai/client": "workspace:*",
|
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/server": "workspace:*",
|
"@opencode-ai/server": "workspace:*",
|
||||||
@@ -107,15 +106,12 @@
|
|||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"jsonc-parser": "3.3.1",
|
|
||||||
"semver": "catalog:",
|
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/semver": "catalog:",
|
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -304,7 +300,6 @@
|
|||||||
"@effect/sql-sqlite-bun": "catalog:",
|
"@effect/sql-sqlite-bun": "catalog:",
|
||||||
"@ff-labs/fff-bun": "0.9.4",
|
"@ff-labs/fff-bun": "0.9.4",
|
||||||
"@lydell/node-pty": "catalog:",
|
"@lydell/node-pty": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@npmcli/config": "10.8.1",
|
"@npmcli/config": "10.8.1",
|
||||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||||
@@ -583,7 +578,6 @@
|
|||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
"@openauthjs/openauth": "catalog:",
|
"@openauthjs/openauth": "catalog:",
|
||||||
"@opencode-ai/client": "workspace:*",
|
|
||||||
"@opencode-ai/llm": "workspace:*",
|
"@opencode-ai/llm": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/protocol": "workspace:*",
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
@@ -680,9 +674,6 @@
|
|||||||
"version": "1.17.11",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@opencode-ai/llm": "workspace:*",
|
|
||||||
"@opencode-ai/protocol": "workspace:*",
|
|
||||||
"@opencode-ai/schema": "workspace:*",
|
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -691,7 +682,6 @@
|
|||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
"@opentui/keymap": "catalog:",
|
"@opentui/keymap": "catalog:",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@tsconfig/bun": "catalog:",
|
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
@@ -746,7 +736,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
|
||||||
"@opencode-ai/server": "workspace:*",
|
"@opencode-ai/server": "workspace:*",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -940,7 +929,6 @@
|
|||||||
"name": "@opencode-ai/tui",
|
"name": "@opencode-ai/tui",
|
||||||
"version": "1.17.11",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/client": "workspace:*",
|
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -948,7 +936,6 @@
|
|||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
"@opentui/keymap": "catalog:",
|
"@opentui/keymap": "catalog:",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
|
||||||
"clipboardy": "4.0.0",
|
"clipboardy": "4.0.0",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -3020,7 +3007,7 @@
|
|||||||
|
|
||||||
"abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
|
"abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
|
||||||
|
|
||||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||||
|
|
||||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||||
|
|
||||||
@@ -3190,7 +3177,7 @@
|
|||||||
|
|
||||||
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
|
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
|
||||||
|
|
||||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
"body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
|
||||||
|
|
||||||
"bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="],
|
"bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="],
|
||||||
|
|
||||||
@@ -3358,7 +3345,7 @@
|
|||||||
|
|
||||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||||
|
|
||||||
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
|
||||||
|
|
||||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||||
|
|
||||||
@@ -3368,7 +3355,7 @@
|
|||||||
|
|
||||||
"cookie-es": ["cookie-es@2.0.1", "", {}, "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA=="],
|
"cookie-es": ["cookie-es@2.0.1", "", {}, "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA=="],
|
||||||
|
|
||||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
|
||||||
|
|
||||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||||
|
|
||||||
@@ -3676,7 +3663,7 @@
|
|||||||
|
|
||||||
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
|
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
|
||||||
|
|
||||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
"express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="],
|
||||||
|
|
||||||
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
|
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
|
||||||
|
|
||||||
@@ -3734,7 +3721,7 @@
|
|||||||
|
|
||||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||||
|
|
||||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
|
||||||
|
|
||||||
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
||||||
|
|
||||||
@@ -3772,7 +3759,7 @@
|
|||||||
|
|
||||||
"framer-motion": ["framer-motion@8.5.5", "", { "dependencies": { "@motionone/dom": "^10.15.3", "hey-listen": "^1.0.8", "tslib": "^2.4.0" }, "optionalDependencies": { "@emotion/is-prop-valid": "^0.8.2" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-5IDx5bxkjWHWUF3CVJoSyUVOtrbAxtzYBBowRE2uYI/6VYhkEBD+rbTHEGuUmbGHRj6YqqSfoG7Aa1cLyWCrBA=="],
|
"framer-motion": ["framer-motion@8.5.5", "", { "dependencies": { "@motionone/dom": "^10.15.3", "hey-listen": "^1.0.8", "tslib": "^2.4.0" }, "optionalDependencies": { "@emotion/is-prop-valid": "^0.8.2" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-5IDx5bxkjWHWUF3CVJoSyUVOtrbAxtzYBBowRE2uYI/6VYhkEBD+rbTHEGuUmbGHRj6YqqSfoG7Aa1cLyWCrBA=="],
|
||||||
|
|
||||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
||||||
|
|
||||||
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
|
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
|
||||||
|
|
||||||
@@ -4336,11 +4323,11 @@
|
|||||||
|
|
||||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||||
|
|
||||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||||
|
|
||||||
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
|
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
|
||||||
|
|
||||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
|
||||||
|
|
||||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||||
|
|
||||||
@@ -4826,7 +4813,7 @@
|
|||||||
|
|
||||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||||
|
|
||||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
||||||
|
|
||||||
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
|
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
|
||||||
|
|
||||||
@@ -5012,7 +4999,7 @@
|
|||||||
|
|
||||||
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
|
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
|
||||||
|
|
||||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
|
||||||
|
|
||||||
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
|
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
|
||||||
|
|
||||||
@@ -5022,7 +5009,7 @@
|
|||||||
|
|
||||||
"seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="],
|
"seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="],
|
||||||
|
|
||||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
"serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
|
||||||
|
|
||||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||||
|
|
||||||
@@ -5338,7 +5325,7 @@
|
|||||||
|
|
||||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||||
|
|
||||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
||||||
|
|
||||||
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
||||||
|
|
||||||
@@ -5916,10 +5903,14 @@
|
|||||||
|
|
||||||
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
|
"@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
"@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
"@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||||
|
|
||||||
"@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
"@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
||||||
@@ -5988,8 +5979,6 @@
|
|||||||
|
|
||||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||||
|
|
||||||
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
|
||||||
|
|
||||||
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||||
|
|
||||||
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||||
@@ -6052,12 +6041,8 @@
|
|||||||
|
|
||||||
"@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
"@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||||
|
|
||||||
"@slack/bolt/express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="],
|
|
||||||
|
|
||||||
"@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
"@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||||
|
|
||||||
"@slack/bolt/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
|
||||||
|
|
||||||
"@slack/oauth/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
|
"@slack/oauth/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
|
||||||
|
|
||||||
"@slack/socket-mode/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
|
"@slack/socket-mode/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
|
||||||
@@ -6124,6 +6109,10 @@
|
|||||||
|
|
||||||
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
|
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
|
||||||
|
|
||||||
|
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
|
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.107", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ=="],
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.107", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
|
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
|
||||||
@@ -6178,6 +6167,10 @@
|
|||||||
|
|
||||||
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
|
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
|
||||||
|
|
||||||
|
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
|
|
||||||
|
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||||
|
|
||||||
"builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
"builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
"builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||||
@@ -6256,10 +6249,16 @@
|
|||||||
|
|
||||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||||
|
|
||||||
|
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
|
|
||||||
|
"express/path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
|
||||||
|
|
||||||
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||||
|
|
||||||
"filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
"filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||||
|
|
||||||
|
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
|
|
||||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
"fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
"fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||||
@@ -6382,6 +6381,8 @@
|
|||||||
|
|
||||||
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||||
|
|
||||||
|
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||||
|
|
||||||
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||||
|
|
||||||
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||||
@@ -6390,6 +6391,10 @@
|
|||||||
|
|
||||||
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||||
|
|
||||||
|
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
|
|
||||||
|
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||||
|
|
||||||
"serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
"serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||||
|
|
||||||
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
@@ -6434,7 +6439,7 @@
|
|||||||
|
|
||||||
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
|
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
|
||||||
|
|
||||||
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||||
|
|
||||||
@@ -6704,6 +6709,28 @@
|
|||||||
|
|
||||||
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||||
|
|
||||||
"@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
|
"@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
|
||||||
|
|
||||||
"@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
"@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||||
@@ -6826,34 +6853,6 @@
|
|||||||
|
|
||||||
"@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
"@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||||
|
|
||||||
"@slack/bolt/express/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
|
||||||
|
|
||||||
"@slack/bolt/raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
|
||||||
|
|
||||||
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||||
@@ -6884,6 +6883,8 @@
|
|||||||
|
|
||||||
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||||
|
|
||||||
|
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||||
@@ -6948,6 +6949,8 @@
|
|||||||
|
|
||||||
"babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
"babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||||
|
|
||||||
|
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||||
@@ -7006,8 +7009,12 @@
|
|||||||
|
|
||||||
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||||
|
|
||||||
|
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
"filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
||||||
|
|
||||||
|
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
"iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
|
"iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
|
||||||
@@ -7044,6 +7051,8 @@
|
|||||||
|
|
||||||
"rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
"rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||||
|
|
||||||
|
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"storybook/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
"storybook/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||||
|
|
||||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
@@ -7058,6 +7067,8 @@
|
|||||||
|
|
||||||
"tw-to-css/tailwindcss/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
"tw-to-css/tailwindcss/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||||
|
|
||||||
|
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
"unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
"unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||||
|
|
||||||
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||||
@@ -7246,6 +7257,10 @@
|
|||||||
|
|
||||||
"@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
"@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||||
|
|
||||||
"@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
"@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||||
|
|
||||||
"@octokit/auth-app/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
"@octokit/auth-app/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||||
@@ -7278,20 +7293,6 @@
|
|||||||
|
|
||||||
"@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
"@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||||
|
|
||||||
"@slack/bolt/express/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
|
||||||
|
|
||||||
"@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
"@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||||
@@ -7420,10 +7421,6 @@
|
|||||||
|
|
||||||
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
"@slack/bolt/express/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
|
||||||
|
|
||||||
"@slack/bolt/express/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
|
||||||
|
|
||||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="],
|
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="],
|
||||||
|
|
||||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="],
|
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="],
|
||||||
|
|||||||
+1
-2
@@ -2,12 +2,11 @@
|
|||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"description": "AI-powered development tool",
|
"description": "AI-powered development tool",
|
||||||
"version": "0.0.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.14",
|
"packageManager": "bun@1.3.14",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||||
"dev:web": "bun --cwd packages/app dev",
|
"dev:web": "bun --cwd packages/app dev",
|
||||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||||
|
|||||||
@@ -791,8 +791,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll active command into view when navigating with keyboard
|
const scrollSlashActiveIntoView = () => {
|
||||||
createEffect(() => {
|
|
||||||
const activeId = slashActive()
|
const activeId = slashActive()
|
||||||
if (!activeId || !slashPopoverRef) return
|
if (!activeId || !slashPopoverRef) return
|
||||||
|
|
||||||
@@ -800,7 +799,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`)
|
const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`)
|
||||||
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
const selectPopoverActive = () => {
|
const selectPopoverActive = () => {
|
||||||
if (store.popover === "at") {
|
if (store.popover === "at") {
|
||||||
const items = atFlat()
|
const items = atFlat()
|
||||||
@@ -1287,6 +1286,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
if (store.popover === "slash") {
|
if (store.popover === "slash") {
|
||||||
slashOnKeyDown(event)
|
slashOnKeyDown(event)
|
||||||
|
if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) {
|
||||||
|
scrollSlashActiveIntoView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
return
|
return
|
||||||
@@ -1406,6 +1408,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
setSlashActive={setSlashActive}
|
setSlashActive={setSlashActive}
|
||||||
onSlashSelect={handleSlashSelect}
|
onSlashSelect={handleSlashSelect}
|
||||||
commandKeybind={command.keybind}
|
commandKeybind={command.keybind}
|
||||||
|
commandKeybindParts={command.keybindParts}
|
||||||
|
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||||
/>
|
/>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Component, For, Match, Show, Switch } from "solid-js"
|
import { Component, For, Match, Show, Switch } from "solid-js"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
|
||||||
export type AtOption =
|
export type AtOption =
|
||||||
@@ -30,6 +32,8 @@ type PromptPopoverProps = {
|
|||||||
setSlashActive: (id: string) => void
|
setSlashActive: (id: string) => void
|
||||||
onSlashSelect: (item: SlashCommand) => void
|
onSlashSelect: (item: SlashCommand) => void
|
||||||
commandKeybind: (id: string) => string | undefined
|
commandKeybind: (id: string) => string | undefined
|
||||||
|
commandKeybindParts: (id: string) => string[]
|
||||||
|
newLayoutDesigns: boolean
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,15 +45,29 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
|||||||
if (props.popover === "slash") props.setSlashPopoverRef(el)
|
if (props.popover === "slash") props.setSlashPopoverRef(el)
|
||||||
}}
|
}}
|
||||||
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
||||||
overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
|
overflow-auto no-scrollbar flex flex-col p-2"
|
||||||
bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
|
classList={{
|
||||||
|
"z-[70] rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": props.newLayoutDesigns,
|
||||||
|
"rounded-[12px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]":
|
||||||
|
!props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.popover === "at"}>
|
<Match when={props.popover === "at"}>
|
||||||
<Show
|
<Show
|
||||||
when={props.atFlat.length > 0}
|
when={props.atFlat.length > 0}
|
||||||
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
|
fallback={
|
||||||
|
<div
|
||||||
|
class="px-2 py-1"
|
||||||
|
classList={{
|
||||||
|
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||||
|
"text-text-weak": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.t("prompt.popover.emptyResults")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<For each={props.atFlat.slice(0, 10)}>
|
<For each={props.atFlat.slice(0, 10)}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
@@ -58,13 +76,29 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
|||||||
if (item.type === "agent") {
|
if (item.type === "agent") {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
class="w-full flex items-center gap-x-2 px-2 py-0.5"
|
||||||
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
classList={{
|
||||||
|
"rounded-[4px]": props.newLayoutDesigns,
|
||||||
|
"rounded-md": !props.newLayoutDesigns,
|
||||||
|
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
|
||||||
|
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
|
||||||
|
}}
|
||||||
onClick={() => props.onAtSelect(item)}
|
onClick={() => props.onAtSelect(item)}
|
||||||
onMouseEnter={() => props.setAtActive(key)}
|
onPointerMove={() => props.setAtActive(key)}
|
||||||
>
|
>
|
||||||
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
||||||
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
|
<span
|
||||||
|
class="whitespace-nowrap"
|
||||||
|
classList={{
|
||||||
|
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||||
|
props.newLayoutDesigns,
|
||||||
|
"text-v2-text-text-base": props.newLayoutDesigns,
|
||||||
|
"text-14-regular": !props.newLayoutDesigns,
|
||||||
|
"text-text-strong": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
@{item.name}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -75,16 +109,44 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
class="w-full flex items-center gap-x-2 px-2 py-0.5"
|
||||||
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
classList={{
|
||||||
|
"rounded-[4px]": props.newLayoutDesigns,
|
||||||
|
"rounded-md": !props.newLayoutDesigns,
|
||||||
|
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
|
||||||
|
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
|
||||||
|
}}
|
||||||
onClick={() => props.onAtSelect(item)}
|
onClick={() => props.onAtSelect(item)}
|
||||||
onMouseEnter={() => props.setAtActive(key)}
|
onPointerMove={() => props.setAtActive(key)}
|
||||||
>
|
>
|
||||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
||||||
<div class="flex items-center text-14-regular min-w-0">
|
<div
|
||||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
|
class="flex items-center min-w-0"
|
||||||
|
classList={{
|
||||||
|
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||||
|
props.newLayoutDesigns,
|
||||||
|
"text-14-regular": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="whitespace-nowrap truncate min-w-0"
|
||||||
|
classList={{
|
||||||
|
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||||
|
"text-text-weak": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{directory}
|
||||||
|
</span>
|
||||||
<Show when={!isDirectory}>
|
<Show when={!isDirectory}>
|
||||||
<span class="text-text-strong whitespace-nowrap">{filename}</span>
|
<span
|
||||||
|
class="whitespace-nowrap"
|
||||||
|
classList={{
|
||||||
|
"text-v2-text-text-base": props.newLayoutDesigns,
|
||||||
|
"text-text-strong": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filename}
|
||||||
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -96,41 +158,98 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
|||||||
<Match when={props.popover === "slash"}>
|
<Match when={props.popover === "slash"}>
|
||||||
<Show
|
<Show
|
||||||
when={props.slashFlat.length > 0}
|
when={props.slashFlat.length > 0}
|
||||||
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
|
fallback={
|
||||||
|
<div
|
||||||
|
class="px-2 py-1"
|
||||||
|
classList={{
|
||||||
|
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||||
|
"text-text-weak": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.t("prompt.popover.emptyCommands")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<For each={props.slashFlat}>
|
<For each={props.slashFlat}>
|
||||||
{(cmd) => (
|
{(cmd) => {
|
||||||
<button
|
const keybind = () => props.commandKeybind(cmd.id)
|
||||||
data-slash-id={cmd.id}
|
const keybindParts = () => props.commandKeybindParts(cmd.id)
|
||||||
classList={{
|
return (
|
||||||
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
|
<button
|
||||||
"bg-surface-raised-base-hover": props.slashActive === cmd.id,
|
data-slash-id={cmd.id}
|
||||||
}}
|
classList={{
|
||||||
onClick={() => props.onSlashSelect(cmd)}
|
"w-full flex items-center justify-between gap-4 px-2 py-1": true,
|
||||||
onMouseEnter={() => props.setSlashActive(cmd.id)}
|
"rounded-[4px] scroll-my-2": props.newLayoutDesigns,
|
||||||
>
|
"rounded-md": !props.newLayoutDesigns,
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.slashActive === cmd.id,
|
||||||
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
|
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.slashActive === cmd.id,
|
||||||
<Show when={cmd.description}>
|
}}
|
||||||
<span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
|
onClick={() => props.onSlashSelect(cmd)}
|
||||||
</Show>
|
onPointerMove={() => props.setSlashActive(cmd.id)}
|
||||||
</div>
|
>
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
|
<span
|
||||||
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
|
class="whitespace-nowrap"
|
||||||
{cmd.source === "skill"
|
classList={{
|
||||||
? props.t("prompt.slash.badge.skill")
|
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||||
: cmd.source === "mcp"
|
props.newLayoutDesigns,
|
||||||
? props.t("prompt.slash.badge.mcp")
|
"text-v2-text-text-base": props.newLayoutDesigns,
|
||||||
: props.t("prompt.slash.badge.custom")}
|
"text-14-regular": !props.newLayoutDesigns,
|
||||||
|
"text-text-strong": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
/{cmd.trigger}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
<Show when={cmd.description}>
|
||||||
<Show when={props.commandKeybind(cmd.id)}>
|
<span
|
||||||
<span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
|
class="truncate"
|
||||||
</Show>
|
classList={{
|
||||||
</div>
|
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||||
</button>
|
props.newLayoutDesigns,
|
||||||
)}
|
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||||
|
"text-14-regular": !props.newLayoutDesigns,
|
||||||
|
"text-text-weak": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cmd.description}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
|
||||||
|
<Show
|
||||||
|
when={props.newLayoutDesigns}
|
||||||
|
fallback={
|
||||||
|
<span class="text-11-regular px-1.5 py-0.5 rounded bg-surface-base text-text-subtle">
|
||||||
|
{cmd.source === "skill"
|
||||||
|
? props.t("prompt.slash.badge.skill")
|
||||||
|
: cmd.source === "mcp"
|
||||||
|
? props.t("prompt.slash.badge.mcp")
|
||||||
|
: props.t("prompt.slash.badge.custom")}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tag>
|
||||||
|
{cmd.source === "skill"
|
||||||
|
? props.t("prompt.slash.badge.skill")
|
||||||
|
: cmd.source === "mcp"
|
||||||
|
? props.t("prompt.slash.badge.mcp")
|
||||||
|
: props.t("prompt.slash.badge.custom")}
|
||||||
|
</Tag>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.newLayoutDesigns ? keybindParts().length > 0 : keybind()}>
|
||||||
|
<Show
|
||||||
|
when={props.newLayoutDesigns}
|
||||||
|
fallback={<span class="text-12-regular text-text-subtle">{keybind()}</span>}
|
||||||
|
>
|
||||||
|
<KeybindV2 keys={keybindParts()} variant="neutral" />
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Match, Show, Switch, createMemo } from "solid-js"
|
import { Match, Show, Switch, createMemo } from "solid-js"
|
||||||
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
||||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||||
|
import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
@@ -9,12 +11,13 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
|
|
||||||
interface SessionContextUsageProps {
|
interface SessionContextUsageProps {
|
||||||
variant?: "button" | "indicator"
|
variant?: "button" | "indicator"
|
||||||
|
buttonAppearance?: "default" | "v2"
|
||||||
placement?: TooltipProps["placement"]
|
placement?: TooltipProps["placement"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,12 +42,14 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
const { params, tabs, view } = useSessionLayout()
|
const { params, tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
const variant = createMemo(() => props.variant ?? "button")
|
const variant = createMemo(() => props.variant ?? "button")
|
||||||
|
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
|
||||||
const tabState = createSessionTabs({
|
const tabState = createSessionTabs({
|
||||||
tabs,
|
tabs,
|
||||||
pathFromTab: file.pathFromTab,
|
pathFromTab: file.pathFromTab,
|
||||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||||
})
|
})
|
||||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||||
|
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||||
|
|
||||||
const usd = createMemo(
|
const usd = createMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -54,10 +59,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||||
const context = createMemo(() => metrics().context)
|
const tokens = createMemo(() => info()?.tokens)
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
return usd().format(metrics().totalCost)
|
return usd().format(info()?.cost ?? 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
const openContext = () => {
|
const openContext = () => {
|
||||||
@@ -79,21 +84,30 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
|
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
const circleV2 = () => (
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<ProgressCircleV2 percentage={context()?.usage ?? 0} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const tooltipValue = () => (
|
const tooltipValue = () => (
|
||||||
<div>
|
<div>
|
||||||
|
<Show when={tokens()}>
|
||||||
|
{(value) => (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-text-invert-strong">
|
||||||
|
{getSessionTokenTotal(value())?.toLocaleString(language.intl())}
|
||||||
|
</span>
|
||||||
|
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
<Show when={context()}>
|
<Show when={context()}>
|
||||||
{(ctx) => (
|
{(ctx) => (
|
||||||
<>
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
|
||||||
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span>
|
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
|
||||||
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
|
|
||||||
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -108,6 +122,16 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
|
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={variant() === "indicator"}>{circle()}</Match>
|
<Match when={variant() === "indicator"}>{circle()}</Match>
|
||||||
|
<Match when={buttonAppearance() === "v2"}>
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="large"
|
||||||
|
icon={circleV2()}
|
||||||
|
onClick={openContext}
|
||||||
|
aria-label={language.t("context.usage.view")}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||||
|
|
||||||
const assistant = (
|
const assistant = (
|
||||||
id: string,
|
id: string,
|
||||||
@@ -37,8 +37,8 @@ const user = (id: string) => {
|
|||||||
} as unknown as Message
|
} as unknown as Message
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("getSessionContextMetrics", () => {
|
describe("getSessionContext", () => {
|
||||||
test("computes totals and usage from latest assistant with tokens", () => {
|
test("computes usage from latest assistant with tokens", () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
user("u1"),
|
user("u1"),
|
||||||
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
|
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
|
||||||
@@ -57,45 +57,52 @@ describe("getSessionContextMetrics", () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const metrics = getSessionContextMetrics(messages, providers)
|
const ctx = getSessionContext(messages, providers)
|
||||||
|
|
||||||
expect(metrics.totalCost).toBe(1.75)
|
expect(ctx?.message.id).toBe("a2")
|
||||||
expect(metrics.context?.message.id).toBe("a2")
|
expect(ctx?.usage).toBe(50)
|
||||||
expect(metrics.context?.total).toBe(500)
|
expect(ctx?.providerLabel).toBe("OpenAI")
|
||||||
expect(metrics.context?.usage).toBe(50)
|
expect(ctx?.modelLabel).toBe("GPT-4.1")
|
||||||
expect(metrics.context?.providerLabel).toBe("OpenAI")
|
|
||||||
expect(metrics.context?.modelLabel).toBe("GPT-4.1")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves fallback labels and null usage when model metadata is missing", () => {
|
test("preserves fallback labels and null usage when model metadata is missing", () => {
|
||||||
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
|
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
|
||||||
const providers = [{ id: "p-1", models: {} }]
|
const providers = [{ id: "p-1", models: {} }]
|
||||||
|
|
||||||
const metrics = getSessionContextMetrics(messages, providers)
|
const ctx = getSessionContext(messages, providers)
|
||||||
|
|
||||||
expect(metrics.context?.providerLabel).toBe("p-1")
|
expect(ctx?.providerLabel).toBe("p-1")
|
||||||
expect(metrics.context?.modelLabel).toBe("m-1")
|
expect(ctx?.modelLabel).toBe("m-1")
|
||||||
expect(metrics.context?.limit).toBeUndefined()
|
expect(ctx?.limit).toBeUndefined()
|
||||||
expect(metrics.context?.usage).toBeNull()
|
expect(ctx?.usage).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("recomputes when message array is mutated in place", () => {
|
test("recomputes when message array is mutated in place", () => {
|
||||||
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
|
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
|
||||||
const providers = [{ id: "openai", models: {} }]
|
const providers = [{ id: "openai", models: {} }]
|
||||||
|
|
||||||
const one = getSessionContextMetrics(messages, providers)
|
const one = getSessionContext(messages, providers)
|
||||||
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
|
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
|
||||||
const two = getSessionContextMetrics(messages, providers)
|
const two = getSessionContext(messages, providers)
|
||||||
|
|
||||||
expect(one.context?.message.id).toBe("a1")
|
expect(one?.message.id).toBe("a1")
|
||||||
expect(two.context?.message.id).toBe("a2")
|
expect(two?.message.id).toBe("a2")
|
||||||
expect(two.totalCost).toBe(1)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns empty metrics when inputs are undefined", () => {
|
test("returns undefined when inputs are undefined", () => {
|
||||||
const metrics = getSessionContextMetrics(undefined, undefined)
|
const ctx = getSessionContext(undefined, undefined)
|
||||||
|
|
||||||
expect(metrics.totalCost).toBe(0)
|
expect(ctx).toBeUndefined()
|
||||||
expect(metrics.context).toBeUndefined()
|
})
|
||||||
|
|
||||||
|
test("computes stored session token totals", () => {
|
||||||
|
expect(
|
||||||
|
getSessionTokenTotal({
|
||||||
|
input: 10,
|
||||||
|
output: 20,
|
||||||
|
reasoning: 30,
|
||||||
|
cache: { read: 40, write: 50 },
|
||||||
|
}),
|
||||||
|
).toBe(150)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
|
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
type Provider = {
|
type Provider = {
|
||||||
id: string
|
id: string
|
||||||
@@ -21,19 +21,9 @@ type Context = {
|
|||||||
modelLabel: string
|
modelLabel: string
|
||||||
limit: number | undefined
|
limit: number | undefined
|
||||||
input: number
|
input: number
|
||||||
output: number
|
|
||||||
reasoning: number
|
|
||||||
cacheRead: number
|
|
||||||
cacheWrite: number
|
|
||||||
total: number
|
|
||||||
usage: number | null
|
usage: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metrics = {
|
|
||||||
totalCost: number
|
|
||||||
context: Context | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenTotal = (msg: AssistantMessage) => {
|
const tokenTotal = (msg: AssistantMessage) => {
|
||||||
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
||||||
}
|
}
|
||||||
@@ -47,10 +37,9 @@ const lastAssistantWithTokens = (messages: Message[]) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => {
|
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
|
||||||
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
|
|
||||||
const message = lastAssistantWithTokens(messages)
|
const message = lastAssistantWithTokens(messages)
|
||||||
if (!message) return { totalCost, context: undefined }
|
if (!message) return undefined
|
||||||
|
|
||||||
const provider = providers.find((item) => item.id === message.providerID)
|
const provider = providers.find((item) => item.id === message.providerID)
|
||||||
const model = provider?.models[message.modelID]
|
const model = provider?.models[message.modelID]
|
||||||
@@ -58,25 +47,22 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics =>
|
|||||||
const total = tokenTotal(message)
|
const total = tokenTotal(message)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalCost,
|
message,
|
||||||
context: {
|
provider,
|
||||||
message,
|
model,
|
||||||
provider,
|
providerLabel: provider?.name ?? message.providerID,
|
||||||
model,
|
modelLabel: model?.name ?? message.modelID,
|
||||||
providerLabel: provider?.name ?? message.providerID,
|
limit,
|
||||||
modelLabel: model?.name ?? message.modelID,
|
input: message.tokens.input,
|
||||||
limit,
|
usage: limit ? Math.round((total / limit) * 100) : null,
|
||||||
input: message.tokens.input,
|
|
||||||
output: message.tokens.output,
|
|
||||||
reasoning: message.tokens.reasoning,
|
|
||||||
cacheRead: message.tokens.cache.read,
|
|
||||||
cacheWrite: message.tokens.cache.write,
|
|
||||||
total,
|
|
||||||
usage: limit ? Math.round((total / limit) * 100) : null,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) {
|
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
|
||||||
return build(messages, providers)
|
return build(messages, providers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
|
||||||
|
if (!tokens) return undefined
|
||||||
|
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||||
import { createSessionContextFormatter } from "./session-context-format"
|
import { createSessionContextFormatter } from "./session-context-format"
|
||||||
|
|
||||||
@@ -134,12 +134,12 @@ export function SessionContextTab() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||||
const ctx = createMemo(() => metrics().context)
|
const tokens = createMemo(() => info()?.tokens)
|
||||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
return usd().format(metrics().totalCost)
|
return usd().format(info()?.cost ?? 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
const counts = createMemo(() => {
|
const counts = createMemo(() => {
|
||||||
@@ -204,14 +204,14 @@ export function SessionContextTab() {
|
|||||||
{ label: "context.stats.provider", value: providerLabel },
|
{ label: "context.stats.provider", value: providerLabel },
|
||||||
{ label: "context.stats.model", value: modelLabel },
|
{ label: "context.stats.model", value: modelLabel },
|
||||||
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
||||||
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
|
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
|
||||||
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
||||||
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
|
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
|
||||||
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
|
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
|
||||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
|
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
|
||||||
{
|
{
|
||||||
label: "context.stats.cacheTokens",
|
label: "context.stats.cacheTokens",
|
||||||
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
|
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
|
||||||
},
|
},
|
||||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
||||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { batch, createEffect, createMemo } from "solid-js"
|
import { batch, createEffect, createMemo, startTransition } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
@@ -294,19 +294,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
model.set({ providerID: entry.provider.id, modelID: entry.id })
|
model.set({ providerID: entry.provider.id, modelID: entry.id })
|
||||||
},
|
},
|
||||||
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
|
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||||
batch(() => {
|
startTransition(() =>
|
||||||
setStore("last", {
|
batch(() => {
|
||||||
type: "model",
|
setStore("last", {
|
||||||
agent: agent.current()?.name,
|
type: "model",
|
||||||
model: item ?? null,
|
agent: agent.current()?.name,
|
||||||
variant: selected(),
|
model: item ?? null,
|
||||||
})
|
variant: selected(),
|
||||||
write({ model: item })
|
})
|
||||||
if (!item) return
|
write({ model: item })
|
||||||
models.setVisibility(item, true)
|
if (!item) return
|
||||||
if (!options?.recent) return
|
models.setVisibility(item, true)
|
||||||
models.recent.push(item)
|
if (!options?.recent) return
|
||||||
})
|
models.recent.push(item)
|
||||||
|
}),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
visible(item: ModelKey) {
|
visible(item: ModelKey) {
|
||||||
return models.visible(item)
|
return models.visible(item)
|
||||||
@@ -335,19 +337,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
return Object.keys(item.variants)
|
return Object.keys(item.variants)
|
||||||
},
|
},
|
||||||
set(value: string | undefined) {
|
set(value: string | undefined) {
|
||||||
batch(() => {
|
startTransition(() =>
|
||||||
const model = current()
|
batch(() => {
|
||||||
setStore("last", {
|
const model = current()
|
||||||
type: "variant",
|
setStore("last", {
|
||||||
agent: agent.current()?.name,
|
type: "variant",
|
||||||
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
|
agent: agent.current()?.name,
|
||||||
variant: value ?? null,
|
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
|
||||||
})
|
variant: value ?? null,
|
||||||
write({ variant: value ?? null })
|
})
|
||||||
if (model) {
|
write({ variant: value ?? null })
|
||||||
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
|
if (model) {
|
||||||
}
|
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
|
||||||
})
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
cycle() {
|
cycle() {
|
||||||
const items = this.list()
|
const items = this.list()
|
||||||
|
|||||||
@@ -138,12 +138,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
const next = { type: "session" as const, ...tab }
|
const next = { type: "session" as const, ...tab }
|
||||||
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
setStore(
|
void startTransition(() => {
|
||||||
produce((tabs) => {
|
setStore(
|
||||||
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
|
produce((tabs) => {
|
||||||
tabs.push(next)
|
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
|
||||||
}),
|
tabs.push(next)
|
||||||
)
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
return next
|
return next
|
||||||
},
|
},
|
||||||
reorder(keys: string[]) {
|
reorder(keys: string[]) {
|
||||||
|
|||||||
@@ -108,6 +108,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-session-group-header::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -12px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--v2-background-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-session-group-header::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--v2-background-bg-base) 0%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 92.0456%, transparent) 7.93%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 84.9947%, transparent) 14.14%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 78.6813%, transparent) 19%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 72.9394%, transparent) 22.85%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 67.6028%, transparent) 26.05%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 62.5055%, transparent) 28.95%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 57.4815%, transparent) 31.91%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 52.3647%, transparent) 35.27%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 46.989%, transparent) 39.4%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 41.1884%, transparent) 44.65%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 34.7969%, transparent) 51.36%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 27.6484%, transparent) 59.9%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 19.5767%, transparent) 70.62%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 10.416%, transparent) 83.87%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
[data-slot="titlebar-update-loader"] {
|
[data-slot="titlebar-update-loader"] {
|
||||||
display: block;
|
display: block;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -132,4 +171,66 @@
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes home-projects-fade-top {
|
||||||
|
from {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes home-projects-fade-bottom {
|
||||||
|
from {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="home-projects-scroll"] {
|
||||||
|
timeline-scope: --home-projects-scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="home-projects-scroll"]::before,
|
||||||
|
[data-slot="home-projects-scroll"]::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
height: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="home-projects-scroll"]::before {
|
||||||
|
top: 0;
|
||||||
|
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="home-projects-scroll"]::after {
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(to top, var(--v2-background-bg-base), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) {
|
||||||
|
[data-slot="home-projects-scroll"] .scroll-view__viewport {
|
||||||
|
scroll-timeline: --home-projects-scroll y;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="home-projects-scroll"]::before {
|
||||||
|
animation: home-projects-fade-top linear both;
|
||||||
|
animation-timeline: --home-projects-scroll;
|
||||||
|
animation-range: 0 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="home-projects-scroll"]::after {
|
||||||
|
animation: home-projects-fade-bottom linear both;
|
||||||
|
animation-timeline: --home-projects-scroll;
|
||||||
|
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+235
-89
@@ -1,5 +1,6 @@
|
|||||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import {
|
import {
|
||||||
|
type ComponentProps,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createResource,
|
createResource,
|
||||||
@@ -68,6 +69,9 @@ import { archiveHomeSession } from "./home-session-archive"
|
|||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
|
|
||||||
const HOME_SESSION_LIMIT = 64
|
const HOME_SESSION_LIMIT = 64
|
||||||
|
const HOME_SESSION_HEADER_STICKY_TOP = 12
|
||||||
|
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
|
||||||
|
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
|
||||||
const SHOW_HOME_SESSION_ARCHIVE = false
|
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||||
const HOME_ROW_LAYOUT =
|
const HOME_ROW_LAYOUT =
|
||||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
||||||
@@ -133,6 +137,107 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
|
|||||||
return `${pathKey(record.session.directory)}:${record.session.id}`
|
return `${pathKey(record.session.directory)}:${record.session.id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
||||||
|
let viewport: HTMLDivElement | undefined
|
||||||
|
let content: HTMLDivElement | undefined
|
||||||
|
let positionFrame: number | undefined
|
||||||
|
let resizeObserver: ResizeObserver | undefined
|
||||||
|
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
|
||||||
|
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
|
||||||
|
const [state, setState] = createStore({
|
||||||
|
titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const items = groups()
|
||||||
|
const ids = new Set(items.map((group) => group.id))
|
||||||
|
headerRefs.forEach((_, id) => {
|
||||||
|
if (!ids.has(id)) headerRefs.delete(id)
|
||||||
|
})
|
||||||
|
headerOffsets.forEach((_, id) => {
|
||||||
|
if (!ids.has(id)) headerOffsets.delete(id)
|
||||||
|
})
|
||||||
|
if (items.length === 0) {
|
||||||
|
content = undefined
|
||||||
|
bindResizeObserver()
|
||||||
|
}
|
||||||
|
queuePositionUpdate()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
function setViewport(el: HTMLDivElement) {
|
||||||
|
viewport = el
|
||||||
|
bindResizeObserver()
|
||||||
|
queuePositionUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContentRef(el: HTMLDivElement) {
|
||||||
|
content = el
|
||||||
|
bindResizeObserver()
|
||||||
|
queuePositionUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeaderRef(id: HomeSessionGroup["id"], el: HTMLDivElement) {
|
||||||
|
headerRefs.set(id, el)
|
||||||
|
queuePositionUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuePositionUpdate() {
|
||||||
|
if (typeof requestAnimationFrame === "undefined") {
|
||||||
|
updatePositionCache()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (positionFrame !== undefined) return
|
||||||
|
positionFrame = requestAnimationFrame(() => {
|
||||||
|
positionFrame = undefined
|
||||||
|
updatePositionCache()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePositionCache() {
|
||||||
|
if (!viewport) return
|
||||||
|
groups().forEach((group) => {
|
||||||
|
const el = headerRefs.get(group.id)
|
||||||
|
if (!el) return
|
||||||
|
headerOffsets.set(group.id, el.offsetTop)
|
||||||
|
})
|
||||||
|
update(viewport.scrollTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(scrollTop: number) {
|
||||||
|
const items = groups()
|
||||||
|
items.forEach((group, index) => {
|
||||||
|
const nextOffset = items
|
||||||
|
.slice(index + 1)
|
||||||
|
.map((item) => headerOffsets.get(item.id))
|
||||||
|
.find((offset) => offset !== undefined)
|
||||||
|
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
|
||||||
|
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
|
||||||
|
const opacity =
|
||||||
|
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
|
||||||
|
setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleOpacity(id: HomeSessionGroup["id"]) {
|
||||||
|
return state.titleOpacity[id] ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindResizeObserver() {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
if (typeof ResizeObserver === "undefined") return
|
||||||
|
resizeObserver = new ResizeObserver(() => queuePositionUpdate())
|
||||||
|
if (viewport) resizeObserver.observe(viewport)
|
||||||
|
if (content) resizeObserver.observe(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
|
||||||
|
}
|
||||||
|
|
||||||
export function NewHome() {
|
export function NewHome() {
|
||||||
const sync = useServerSync()
|
const sync = useServerSync()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
@@ -223,6 +328,7 @@ export function NewHome() {
|
|||||||
})
|
})
|
||||||
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
|
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
|
||||||
const groups = createMemo(() => groupSessions(records(), language))
|
const groups = createMemo(() => groupSessions(records(), language))
|
||||||
|
const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups)
|
||||||
const prefetched = new Set<string>()
|
const prefetched = new Set<string>()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -435,7 +541,7 @@ export function NewHome() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12"
|
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative"
|
||||||
aria-label={language.t("sidebar.project.recentSessions")}
|
aria-label={language.t("sidebar.project.recentSessions")}
|
||||||
>
|
>
|
||||||
<HomeSessionSearch
|
<HomeSessionSearch
|
||||||
@@ -456,7 +562,25 @@ export function NewHome() {
|
|||||||
onClose={closeSearch}
|
onClose={closeSearch}
|
||||||
onSelect={selectSearchSession}
|
onSelect={selectSearchSession}
|
||||||
/>
|
/>
|
||||||
<ScrollView class="mt-3 -mr-3 min-h-0 flex-1">
|
<ScrollView
|
||||||
|
class="mt-3 -mr-3 min-h-0 flex-1 relative"
|
||||||
|
viewportRef={sessionHeaderOpacity.setViewport}
|
||||||
|
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
|
||||||
|
>
|
||||||
|
<Show when={groups().length > 0 && newSessionProject()}>
|
||||||
|
<div class="pointer-events-none absolute top-3 right-3 z-20 flex">
|
||||||
|
<ButtonV2
|
||||||
|
data-action="home-new-session"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="normal"
|
||||||
|
icon="edit"
|
||||||
|
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
||||||
|
onClick={openNewSession}
|
||||||
|
>
|
||||||
|
{language.t("command.session.new")}
|
||||||
|
</ButtonV2>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={!sessionLoad.isLoading}
|
when={!sessionLoad.isLoading}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -469,15 +593,19 @@ export function NewHome() {
|
|||||||
when={groups().length > 0}
|
when={groups().length > 0}
|
||||||
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-6 pt-3 pr-3 pb-16">
|
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
|
||||||
<For each={groups()}>
|
<For each={groups()}>
|
||||||
{(group, index) => (
|
{(group, index) => (
|
||||||
<div class="flex min-w-0 flex-col gap-4">
|
<>
|
||||||
<HomeSessionGroupHeader
|
<HomeSessionGroupHeader
|
||||||
title={group.title}
|
title={group.title}
|
||||||
onNewSession={index() === 0 && newSessionProject() ? openNewSession : undefined}
|
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
|
||||||
|
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
|
||||||
|
elevated={index() === 0}
|
||||||
/>
|
/>
|
||||||
<div class="flex min-w-0 flex-col gap-px">
|
<div
|
||||||
|
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
|
||||||
|
>
|
||||||
<For each={group.sessions}>
|
<For each={group.sessions}>
|
||||||
{(record) => (
|
{(record) => (
|
||||||
<HomeSessionRow
|
<HomeSessionRow
|
||||||
@@ -491,7 +619,7 @@ export function NewHome() {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
@@ -540,10 +668,10 @@ function HomeProjectColumn(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
|
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]"
|
||||||
aria-label={props.language.t("home.projects")}
|
aria-label={props.language.t("home.projects")}
|
||||||
>
|
>
|
||||||
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
|
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5">
|
||||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||||
<Show when={global.servers.list().length === 1}>
|
<Show when={global.servers.list().length === 1}>
|
||||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||||
@@ -560,42 +688,51 @@ function HomeProjectColumn(props: {
|
|||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show
|
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
|
||||||
when={global.servers.list().length > 1}
|
<Show
|
||||||
fallback={<HomeProjectList {...props} server={global.servers.list()[0]!} />}
|
when={global.servers.list().length > 1}
|
||||||
>
|
fallback={
|
||||||
<For each={global.servers.list()}>
|
<div class="pr-3">
|
||||||
{(item) => {
|
<HomeProjectList {...props} server={global.servers.list()[0]!} />
|
||||||
const key = ServerConnection.key(item)
|
</div>
|
||||||
const healthy = () => !!global.servers.health[key]?.healthy
|
}
|
||||||
const serverCtx = global.ensureServerCtx(item)
|
>
|
||||||
const collapsed = () => !!state().collapsed[key]
|
<div class="flex min-w-0 flex-col gap-1 pr-3">
|
||||||
return (
|
<For each={global.servers.list()}>
|
||||||
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
{(item) => {
|
||||||
<HomeServerRow
|
const key = ServerConnection.key(item)
|
||||||
server={item}
|
const healthy = () => !!global.servers.health[key]?.healthy
|
||||||
selected={props.selected.server === key && !props.selected.directory}
|
const serverCtx = global.ensureServerCtx(item)
|
||||||
healthy={healthy()}
|
const projects = () => serverCtx.projects.list()
|
||||||
collapsed={collapsed()}
|
const hasProjects = () => projects().length > 0
|
||||||
health={global.servers.health[key]}
|
const collapsed = () => !!state().collapsed[key]
|
||||||
controller={controller}
|
return (
|
||||||
focusServer={props.focusServer}
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
chooseProject={props.chooseProject}
|
<HomeServerRow
|
||||||
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
server={item}
|
||||||
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
|
selected={props.selected.server === key && !props.selected.directory}
|
||||||
language={props.language}
|
collapsed={collapsed()}
|
||||||
/>
|
health={global.servers.health[key]}
|
||||||
<Show when={healthy() && !collapsed()}>
|
controller={controller}
|
||||||
<div class="mx-3 h-px bg-v2-border-border-base" />
|
focusServer={props.focusServer}
|
||||||
<HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} />
|
chooseProject={props.chooseProject}
|
||||||
</Show>
|
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
||||||
</div>
|
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
|
||||||
)
|
language={props.language}
|
||||||
}}
|
/>
|
||||||
</For>
|
<Show when={healthy() && hasProjects() && !collapsed()}>
|
||||||
</Show>
|
<div class="mx-3 h-px bg-v2-border-border-base" />
|
||||||
|
<HomeProjectList {...props} server={item} projects={projects()} />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</ScrollView>
|
||||||
<HomeUtilityNav
|
<HomeUtilityNav
|
||||||
class="mt-4 hidden lg:flex"
|
class="mb-8 mt-4 hidden shrink-0 lg:flex"
|
||||||
openSettings={props.openSettings}
|
openSettings={props.openSettings}
|
||||||
openHelp={props.openHelp}
|
openHelp={props.openHelp}
|
||||||
language={props.language}
|
language={props.language}
|
||||||
@@ -635,7 +772,6 @@ function HomeUtilityNav(props: {
|
|||||||
function HomeServerRow(props: {
|
function HomeServerRow(props: {
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
selected: boolean
|
selected: boolean
|
||||||
healthy: boolean
|
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
health: ServerHealth | undefined
|
health: ServerHealth | undefined
|
||||||
controller: ReturnType<typeof useServerManagementController>
|
controller: ReturnType<typeof useServerManagementController>
|
||||||
@@ -645,39 +781,46 @@ function HomeServerRow(props: {
|
|||||||
toggleCollapsed: () => void
|
toggleCollapsed: () => void
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
}) {
|
}) {
|
||||||
|
const global = useGlobal()
|
||||||
const [state, setState] = createStore({ menuOpen: false })
|
const [state, setState] = createStore({ menuOpen: false })
|
||||||
|
const healthy = () => !!props.health?.healthy
|
||||||
|
const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0
|
||||||
return (
|
return (
|
||||||
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
|
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
||||||
data-selected={props.selected ? "" : undefined}
|
data-selected={props.selected ? "" : undefined}
|
||||||
disabled={!props.healthy}
|
disabled={!healthy()}
|
||||||
onClick={() => props.focusServer(props.server)}
|
onClick={() => props.focusServer(props.server)}
|
||||||
>
|
>
|
||||||
<Show when={props.healthy}>
|
<span
|
||||||
<span
|
data-action="home-server-collapse"
|
||||||
data-action="home-server-collapse"
|
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted"
|
||||||
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
classList={{
|
||||||
aria-label={
|
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
|
||||||
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
|
"cursor-default opacity-40": !canToggle(),
|
||||||
}
|
}}
|
||||||
aria-expanded={!props.collapsed}
|
aria-label={
|
||||||
onClick={(event) => {
|
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
|
||||||
event.preventDefault()
|
}
|
||||||
event.stopPropagation()
|
aria-disabled={!canToggle()}
|
||||||
props.toggleCollapsed()
|
aria-expanded={canToggle() ? !props.collapsed : undefined}
|
||||||
}}
|
onClick={(event) => {
|
||||||
onPointerDown={(event) => event.preventDefault()}
|
event.preventDefault()
|
||||||
>
|
event.stopPropagation()
|
||||||
<IconV2
|
if (!canToggle()) return
|
||||||
name="chevron-down"
|
props.toggleCollapsed()
|
||||||
size="small"
|
}}
|
||||||
class="transition-transform duration-150 ease-in-out"
|
onPointerDown={(event) => event.preventDefault()}
|
||||||
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
|
>
|
||||||
/>
|
<IconV2
|
||||||
</span>
|
name="chevron-down"
|
||||||
</Show>
|
size="small"
|
||||||
|
class="transition-transform duration-150 ease-in-out"
|
||||||
|
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
|
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
|
||||||
<ServerHealthIndicator health={props.health} />
|
<ServerHealthIndicator health={props.health} />
|
||||||
</div>
|
</div>
|
||||||
@@ -854,6 +997,7 @@ function HomeSessionLeading(props: {
|
|||||||
session: Session
|
session: Session
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
activeServer: boolean
|
activeServer: boolean
|
||||||
|
revealProjectOnHover: boolean
|
||||||
}) {
|
}) {
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
|
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
|
||||||
@@ -871,6 +1015,7 @@ function HomeSessionLeading(props: {
|
|||||||
directory={props.session.directory}
|
directory={props.session.directory}
|
||||||
sessionId={props.session.id}
|
sessionId={props.session.id}
|
||||||
activeServer={props.activeServer}
|
activeServer={props.activeServer}
|
||||||
|
revealProjectOnHover={props.revealProjectOnHover}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -961,7 +1106,7 @@ function HomeSessionSearch(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div ref={root} data-component="home-session-search" class="relative z-10 w-full">
|
<div ref={root} data-component="home-session-search" class="relative z-30 w-full">
|
||||||
<Show when={props.open}>
|
<Show when={props.open}>
|
||||||
<div
|
<div
|
||||||
data-component="home-session-search-panel"
|
data-component="home-session-search-panel"
|
||||||
@@ -1110,6 +1255,7 @@ function HomeSessionSearchResultRow(props: {
|
|||||||
classList={{
|
classList={{
|
||||||
[HOME_SEARCH_RESULT_ROW]: true,
|
[HOME_SEARCH_RESULT_ROW]: true,
|
||||||
"bg-v2-overlay-simple-overlay-hover": props.selected,
|
"bg-v2-overlay-simple-overlay-hover": props.selected,
|
||||||
|
group: !!showProjectName(),
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => props.onHighlight()}
|
onMouseEnter={() => props.onHighlight()}
|
||||||
onClick={() => props.onSelect(props.record.session)}
|
onClick={() => props.onSelect(props.record.session)}
|
||||||
@@ -1119,6 +1265,7 @@ function HomeSessionSearchResultRow(props: {
|
|||||||
session={props.record.session}
|
session={props.record.session}
|
||||||
server={props.server}
|
server={props.server}
|
||||||
activeServer={props.activeServer}
|
activeServer={props.activeServer}
|
||||||
|
revealProjectOnHover={!!showProjectName()}
|
||||||
/>
|
/>
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
@@ -1134,25 +1281,20 @@ function HomeSessionSearchResultRow(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
|
function HomeSessionGroupHeader(props: {
|
||||||
const language = useLanguage()
|
title: string
|
||||||
|
titleOpacity: number
|
||||||
|
ref: ComponentProps<"div">["ref"]
|
||||||
|
elevated?: boolean
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
|
<div
|
||||||
<div class={HOME_SECTION_LABEL}>{props.title}</div>
|
ref={props.ref}
|
||||||
<Show when={props.onNewSession}>
|
class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
|
||||||
{(onNewSession) => (
|
>
|
||||||
<ButtonV2
|
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
|
||||||
data-action="home-new-session"
|
{props.title}
|
||||||
variant="ghost-muted"
|
</div>
|
||||||
size="normal"
|
|
||||||
icon="edit"
|
|
||||||
class="h-7 px-2 [font-weight:530]"
|
|
||||||
onClick={onNewSession()}
|
|
||||||
>
|
|
||||||
{language.t("command.session.new")}
|
|
||||||
</ButtonV2>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1170,7 +1312,10 @@ function HomeSessionRow(props: {
|
|||||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]">
|
<div
|
||||||
|
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
|
||||||
|
classList={{ group: !!showProjectName() }}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-component="home-session-row"
|
data-component="home-session-row"
|
||||||
@@ -1182,6 +1327,7 @@ function HomeSessionRow(props: {
|
|||||||
session={props.record.session}
|
session={props.record.session}
|
||||||
server={props.server}
|
server={props.server}
|
||||||
activeServer={props.activeServer}
|
activeServer={props.activeServer}
|
||||||
|
revealProjectOnHover={!!showProjectName()}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||||
|
|||||||
@@ -11,32 +11,28 @@ export function SessionTabAvatar(props: {
|
|||||||
directory: string
|
directory: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
activeServer: boolean
|
activeServer: boolean
|
||||||
|
revealProjectOnHover?: boolean
|
||||||
}) {
|
}) {
|
||||||
const directory = () => props.directory
|
const directory = () => props.directory
|
||||||
const sessionId = () => props.sessionId
|
const sessionId = () => props.sessionId
|
||||||
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
|
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
|
||||||
|
const projectAvatar = () => (
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||||
|
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||||
|
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||||
|
unread={state.unread()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show when={state.loading()} fallback={projectAvatar()}>
|
||||||
when={state.loading()}
|
|
||||||
fallback={
|
|
||||||
<ProjectAvatar
|
|
||||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
|
||||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
|
||||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
|
||||||
unread={state.unread()}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span class="relative block size-4 shrink-0">
|
<span class="relative block size-4 shrink-0">
|
||||||
<SessionProgressIndicatorV2 class="absolute inset-0 group-hover:invisible" />
|
<SessionProgressIndicatorV2
|
||||||
<span class="invisible absolute inset-0 group-hover:visible">
|
class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`}
|
||||||
<ProjectAvatar
|
/>
|
||||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
<Show when={props.revealProjectOnHover !== false}>
|
||||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
<span class="invisible absolute inset-0 group-hover:visible">{projectAvatar()}</span>
|
||||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
</Show>
|
||||||
unread={state.unread()}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export function SessionComposerRegion(props: {
|
|||||||
</Show>
|
</Show>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"relative z-30": true,
|
"relative z-[70]": true,
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
"margin-top": `${-controller.lift()}px`,
|
"margin-top": `${-controller.lift()}px`,
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
|||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||||
@@ -671,6 +676,34 @@ export function MessageTimeline(props: {
|
|||||||
if (!shareEnabled()) return
|
if (!shareEnabled()) return
|
||||||
unshareMutation.mutate(id)
|
unshareMutation.mutate(id)
|
||||||
}
|
}
|
||||||
|
const copyShareUrl = () => {
|
||||||
|
const url = shareUrl()
|
||||||
|
if (!url) return
|
||||||
|
void navigator.clipboard
|
||||||
|
.writeText(url)
|
||||||
|
.then(() =>
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("session.share.copy.copied"),
|
||||||
|
description: url,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch((err: unknown) =>
|
||||||
|
showToast({
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const selectShareUrlText: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection) return
|
||||||
|
const range = document.createRange()
|
||||||
|
range.selectNodeContents(event.currentTarget)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
@@ -856,6 +889,26 @@ export function MessageTimeline(props: {
|
|||||||
dialog.close()
|
dialog.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settings.general.newLayoutDesigns())
|
||||||
|
return (
|
||||||
|
<DialogV2 fit>
|
||||||
|
<DialogHeader hideClose>
|
||||||
|
<DialogTitleGroup
|
||||||
|
title={language.t("session.delete.title")}
|
||||||
|
description={language.t("session.delete.confirm", { name: name() })}
|
||||||
|
/>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2 variant="danger" onClick={handleDelete}>
|
||||||
|
{language.t("session.delete.button")}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogV2>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={language.t("session.delete.title")} fit>
|
<Dialog title={language.t("session.delete.title")} fit>
|
||||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||||
@@ -960,6 +1013,7 @@ export function MessageTimeline(props: {
|
|||||||
message={message()}
|
message={message()}
|
||||||
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
|
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
|
||||||
turnDurationMs={turnDurationMs(row().userMessageID)}
|
turnDurationMs={turnDurationMs(row().userMessageID)}
|
||||||
|
useV2Actions={settings.general.newLayoutDesigns()}
|
||||||
defaultOpen={defaultOpen()}
|
defaultOpen={defaultOpen()}
|
||||||
toolOpen={toolOpen[part().id] ?? defaultOpen()}
|
toolOpen={toolOpen[part().id] ?? defaultOpen()}
|
||||||
onToolOpenChange={(open) => setToolOpen(part().id, open)}
|
onToolOpenChange={(open) => setToolOpen(part().id, open)}
|
||||||
@@ -1067,6 +1121,7 @@ export function MessageTimeline(props: {
|
|||||||
message={message()}
|
message={message()}
|
||||||
parts={getMsgParts(userMessageRow().userMessageID)}
|
parts={getMsgParts(userMessageRow().userMessageID)}
|
||||||
actions={props.actions}
|
actions={props.actions}
|
||||||
|
useV2Actions={settings.general.newLayoutDesigns()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1304,11 +1359,16 @@ export function MessageTimeline(props: {
|
|||||||
"pr-3": true,
|
"pr-3": true,
|
||||||
"pl-4": settings.general.newLayoutDesigns(),
|
"pl-4": settings.general.newLayoutDesigns(),
|
||||||
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
|
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
|
||||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex items-center gap-1 min-w-0 flex-1": true,
|
||||||
|
"pr-3": !settings.general.newLayoutDesigns(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="flex items-center min-w-0 grow-1">
|
<div class="flex items-center min-w-0 grow-1">
|
||||||
<Show when={parentID()}>
|
<Show when={parentID()}>
|
||||||
<button
|
<button
|
||||||
@@ -1347,8 +1407,17 @@ export function MessageTimeline(props: {
|
|||||||
data-slot="session-title-child"
|
data-slot="session-title-child"
|
||||||
value={title.draft}
|
value={title.draft}
|
||||||
disabled={titleMutation.isPending}
|
disabled={titleMutation.isPending}
|
||||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1"
|
classList={{
|
||||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
"text-14-medium text-text-strong grow-1 min-w-0 pl-1 -ml-1": true,
|
||||||
|
"h-6 leading-4 rounded-[3px] focus:shadow-none focus:outline focus:outline-1 focus:outline-offset-[-1px] focus:outline-v2-border-border-focus":
|
||||||
|
settings.general.newLayoutDesigns(),
|
||||||
|
"rounded-[6px]": !settings.general.newLayoutDesigns(),
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
"--inline-input-shadow": settings.general.newLayoutDesigns()
|
||||||
|
? "none"
|
||||||
|
: "var(--shadow-xs-border-select)",
|
||||||
|
}}
|
||||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -1370,88 +1439,170 @@ export function MessageTimeline(props: {
|
|||||||
</div>
|
</div>
|
||||||
<Show when={sessionID()} keyed>
|
<Show when={sessionID()} keyed>
|
||||||
{(id) => (
|
{(id) => (
|
||||||
<div class="shrink-0 flex items-center gap-3">
|
<div
|
||||||
<SessionContextUsage placement="bottom" />
|
classList={{
|
||||||
|
"shrink-0 flex items-center": true,
|
||||||
|
"gap-2": settings.general.newLayoutDesigns(),
|
||||||
|
"gap-3": !settings.general.newLayoutDesigns(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SessionContextUsage
|
||||||
|
placement="bottom"
|
||||||
|
buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"}
|
||||||
|
/>
|
||||||
<Show when={!parentID()}>
|
<Show when={!parentID()}>
|
||||||
<DropdownMenu
|
<Show
|
||||||
gutter={4}
|
when={settings.general.newLayoutDesigns()}
|
||||||
placement="bottom-end"
|
fallback={
|
||||||
open={title.menuOpen}
|
<DropdownMenu
|
||||||
onOpenChange={(open) => {
|
gutter={4}
|
||||||
setTitle("menuOpen", open)
|
placement="bottom-end"
|
||||||
if (open) return
|
open={title.menuOpen}
|
||||||
}}
|
onOpenChange={(open) => {
|
||||||
>
|
setTitle("menuOpen", open)
|
||||||
<DropdownMenu.Trigger
|
if (open) return
|
||||||
as={IconButton}
|
|
||||||
icon="dot-grid"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
|
||||||
classList={{
|
|
||||||
"bg-surface-base-active": share.open || title.pendingShare,
|
|
||||||
}}
|
|
||||||
aria-label={language.t("common.moreOptions")}
|
|
||||||
aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
|
||||||
ref={(el: HTMLButtonElement) => {
|
|
||||||
more = el
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
style={{ "min-width": "104px" }}
|
|
||||||
onCloseAutoFocus={(event) => {
|
|
||||||
if (title.pendingRename) {
|
|
||||||
event.preventDefault()
|
|
||||||
setTitle("pendingRename", false)
|
|
||||||
openTitleEditor()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (title.pendingShare) {
|
|
||||||
event.preventDefault()
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setShare({ open: true, dismiss: null })
|
|
||||||
setTitle("pendingShare", false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Trigger
|
||||||
onSelect={() => {
|
as={IconButton}
|
||||||
setTitle("pendingRename", true)
|
icon="dot-grid"
|
||||||
setTitle("menuOpen", false)
|
variant="ghost"
|
||||||
|
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||||
|
classList={{
|
||||||
|
"bg-surface-base-active": share.open || title.pendingShare,
|
||||||
}}
|
}}
|
||||||
>
|
aria-label={language.t("common.moreOptions")}
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
||||||
</DropdownMenu.Item>
|
ref={(el: HTMLButtonElement) => {
|
||||||
<Show when={shareEnabled()}>
|
more = el
|
||||||
<DropdownMenu.Item
|
}}
|
||||||
onSelect={() => {
|
/>
|
||||||
setTitle({ pendingShare: true, menuOpen: false })
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
style={{ "min-width": "104px" }}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (title.pendingRename) {
|
||||||
|
event.preventDefault()
|
||||||
|
setTitle("pendingRename", false)
|
||||||
|
openTitleEditor()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (title.pendingShare) {
|
||||||
|
event.preventDefault()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setShare({ open: true, dismiss: null })
|
||||||
|
setTitle("pendingShare", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.Item
|
||||||
{language.t("session.share.action.share")}
|
onSelect={() => {
|
||||||
</DropdownMenu.ItemLabel>
|
setTitle("pendingRename", true)
|
||||||
</DropdownMenu.Item>
|
setTitle("menuOpen", false)
|
||||||
</Show>
|
}}
|
||||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Separator />
|
<Show when={shareEnabled()}>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
onSelect={() => {
|
||||||
|
setTitle({ pendingShare: true, menuOpen: false })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("session.share.action.share")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
|
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||||
|
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuV2
|
||||||
|
gutter={6}
|
||||||
|
placement="bottom-end"
|
||||||
|
open={title.menuOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setTitle("menuOpen", open)
|
||||||
|
if (open) return
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuV2.Trigger
|
||||||
|
as={IconButtonV2}
|
||||||
|
icon={<IconV2 name="outline-dots" />}
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="large"
|
||||||
|
state={share.open || title.pendingShare ? "pressed" : undefined}
|
||||||
|
aria-label={language.t("common.moreOptions")}
|
||||||
|
aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
||||||
|
ref={(el: HTMLButtonElement) => {
|
||||||
|
more = el
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content
|
||||||
|
style={{ width: "120px", "min-width": "120px" }}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (title.pendingRename) {
|
||||||
|
event.preventDefault()
|
||||||
|
setTitle("pendingRename", false)
|
||||||
|
openTitleEditor()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (title.pendingShare) {
|
||||||
|
event.preventDefault()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setShare({ open: true, dismiss: null })
|
||||||
|
setTitle("pendingShare", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
<MenuV2.Item
|
||||||
</DropdownMenu.Item>
|
onSelect={() => {
|
||||||
</DropdownMenu.Content>
|
setTitle("pendingRename", true)
|
||||||
</DropdownMenu.Portal>
|
setTitle("menuOpen", false)
|
||||||
</DropdownMenu>
|
}}
|
||||||
|
>
|
||||||
|
{language.t("common.rename")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
<Show when={shareEnabled()}>
|
||||||
|
<MenuV2.Item
|
||||||
|
onSelect={() => {
|
||||||
|
setTitle({ pendingShare: true, menuOpen: false })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{language.t("session.share.action.share")}...
|
||||||
|
</MenuV2.Item>
|
||||||
|
</Show>
|
||||||
|
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
||||||
|
{language.t("common.archive")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
<MenuV2.Separator />
|
||||||
|
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
|
||||||
|
{language.t("common.delete")}...
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<KobaltePopover
|
<KobaltePopover
|
||||||
open={share.open}
|
open={share.open}
|
||||||
anchorRef={() => more}
|
anchorRef={() => more}
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
gutter={4}
|
gutter={settings.general.newLayoutDesigns() ? 6 : 4}
|
||||||
modal={false}
|
modal={false}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (open) setShare("dismiss", null)
|
if (open) setShare("dismiss", null)
|
||||||
@@ -1461,6 +1612,10 @@ export function MessageTimeline(props: {
|
|||||||
<KobaltePopover.Portal>
|
<KobaltePopover.Portal>
|
||||||
<KobaltePopover.Content
|
<KobaltePopover.Content
|
||||||
data-component="popover-content"
|
data-component="popover-content"
|
||||||
|
classList={{
|
||||||
|
"flex w-80 max-w-none flex-col items-start gap-3 rounded-[10px] border-0 bg-v2-background-bg-layer-01 p-3 shadow-[var(--v2-elevation-floating)]":
|
||||||
|
settings.general.newLayoutDesigns(),
|
||||||
|
}}
|
||||||
style={{ "min-width": "320px" }}
|
style={{ "min-width": "320px" }}
|
||||||
onEscapeKeyDown={(event) => {
|
onEscapeKeyDown={(event) => {
|
||||||
setShare({ dismiss: "escape", open: false })
|
setShare({ dismiss: "escape", open: false })
|
||||||
@@ -1478,24 +1633,90 @@ export function MessageTimeline(props: {
|
|||||||
setShare("dismiss", null)
|
setShare("dismiss", null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col p-3">
|
<Show
|
||||||
<div class="flex flex-col gap-1">
|
when={settings.general.newLayoutDesigns()}
|
||||||
<div class="text-13-medium text-text-strong">
|
fallback={
|
||||||
|
<div class="flex flex-col p-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-13-medium text-text-strong">
|
||||||
|
{language.t("session.share.popover.title")}
|
||||||
|
</div>
|
||||||
|
<div class="text-12-regular text-text-weak">
|
||||||
|
{shareUrl()
|
||||||
|
? language.t("session.share.popover.description.shared")
|
||||||
|
: language.t("session.share.popover.description.unshared")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
|
<Show
|
||||||
|
when={shareUrl()}
|
||||||
|
fallback={
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="primary"
|
||||||
|
class="w-full"
|
||||||
|
onClick={shareSession}
|
||||||
|
disabled={shareMutation.isPending}
|
||||||
|
>
|
||||||
|
{shareMutation.isPending
|
||||||
|
? language.t("session.share.action.publishing")
|
||||||
|
: language.t("session.share.action.publish")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<TextField
|
||||||
|
value={shareUrl() ?? ""}
|
||||||
|
readOnly
|
||||||
|
copyable
|
||||||
|
copyKind="link"
|
||||||
|
tabIndex={-1}
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="secondary"
|
||||||
|
class="w-full shadow-none border border-border-weak-base"
|
||||||
|
onClick={unshareSession}
|
||||||
|
disabled={unshareMutation.isPending}
|
||||||
|
>
|
||||||
|
{unshareMutation.isPending
|
||||||
|
? language.t("session.share.action.unpublishing")
|
||||||
|
: language.t("session.share.action.unpublish")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="primary"
|
||||||
|
class="w-full"
|
||||||
|
onClick={viewShare}
|
||||||
|
disabled={unshareMutation.isPending}
|
||||||
|
>
|
||||||
|
{language.t("session.share.action.view")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex w-full flex-col gap-1.5 px-0.5 pt-0.5">
|
||||||
|
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base [font-variation-settings:'slnt'_0]">
|
||||||
{language.t("session.share.popover.title")}
|
{language.t("session.share.popover.title")}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-12-regular text-text-weak">
|
<div class="select-none text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-variation-settings:'slnt'_0]">
|
||||||
{shareUrl()
|
{shareUrl()
|
||||||
? language.t("session.share.popover.description.shared")
|
? language.t("session.share.popover.description.shared")
|
||||||
: language.t("session.share.popover.description.unshared")}
|
: language.t("session.share.popover.description.unshared")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex flex-col gap-2">
|
<div class="flex w-full flex-col gap-2">
|
||||||
<Show
|
<Show
|
||||||
when={shareUrl()}
|
when={shareUrl()}
|
||||||
fallback={
|
fallback={
|
||||||
<Button
|
<ButtonV2
|
||||||
size="large"
|
variant="contrast"
|
||||||
variant="primary"
|
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onClick={shareSession}
|
onClick={shareSession}
|
||||||
disabled={shareMutation.isPending}
|
disabled={shareMutation.isPending}
|
||||||
@@ -1503,48 +1724,57 @@ export function MessageTimeline(props: {
|
|||||||
{shareMutation.isPending
|
{shareMutation.isPending
|
||||||
? language.t("session.share.action.publishing")
|
? language.t("session.share.action.publishing")
|
||||||
: language.t("session.share.action.publish")}
|
: language.t("session.share.action.publish")}
|
||||||
</Button>
|
</ButtonV2>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<TextField
|
<div
|
||||||
value={shareUrl() ?? ""}
|
class="flex h-8 w-full items-center gap-1.5 rounded-[6px] py-1 pl-2.5 pr-1.5 shadow-[var(--v2-elevation-button-neutral)]"
|
||||||
readOnly
|
style={{
|
||||||
copyable
|
background:
|
||||||
copyKind="link"
|
"linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-button-neutral)",
|
||||||
tabIndex={-1}
|
}}
|
||||||
class="w-full"
|
>
|
||||||
/>
|
<div
|
||||||
<div class="grid grid-cols-2 gap-2">
|
class="min-w-0 flex-1 truncate select-text cursor-text text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-variation-settings:'slnt'_0]"
|
||||||
<Button
|
onClick={selectShareUrlText}
|
||||||
size="large"
|
>
|
||||||
variant="secondary"
|
{shareUrl()}
|
||||||
class={
|
</div>
|
||||||
settings.general.newLayoutDesigns()
|
<IconButtonV2
|
||||||
? "w-full shadow-none border-[0.5px] border-border-weak-base"
|
type="button"
|
||||||
: "w-full shadow-none border border-border-weak-base"
|
size="small"
|
||||||
}
|
variant="ghost-muted"
|
||||||
|
icon={<IconV2 name="outline-copy" />}
|
||||||
|
aria-label={language.t("session.share.copy.copyLink")}
|
||||||
|
onClick={copyShareUrl}
|
||||||
|
/>
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="ghost-muted"
|
||||||
|
icon={<IconV2 name="outline-square-arrow" />}
|
||||||
|
aria-label={language.t("session.share.action.view")}
|
||||||
|
onClick={viewShare}
|
||||||
|
disabled={unshareMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full">
|
||||||
|
<ButtonV2
|
||||||
|
variant="outline"
|
||||||
|
class="w-full"
|
||||||
onClick={unshareSession}
|
onClick={unshareSession}
|
||||||
disabled={unshareMutation.isPending}
|
disabled={unshareMutation.isPending}
|
||||||
>
|
>
|
||||||
{unshareMutation.isPending
|
{unshareMutation.isPending
|
||||||
? language.t("session.share.action.unpublishing")
|
? language.t("session.share.action.unpublishing")
|
||||||
: language.t("session.share.action.unpublish")}
|
: language.t("session.share.action.unpublish")}
|
||||||
</Button>
|
</ButtonV2>
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
variant="primary"
|
|
||||||
class="w-full"
|
|
||||||
onClick={viewShare}
|
|
||||||
disabled={unshareMutation.isPending}
|
|
||||||
>
|
|
||||||
{language.t("session.share.action.view")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
</KobaltePopover.Content>
|
</KobaltePopover.Content>
|
||||||
</KobaltePopover.Portal>
|
</KobaltePopover.Portal>
|
||||||
</KobaltePopover>
|
</KobaltePopover>
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
# V2 CLI and TUI development guide
|
|
||||||
|
|
||||||
## Migration context
|
|
||||||
|
|
||||||
- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
|
|
||||||
- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI.
|
|
||||||
- Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From packages/cli: local V2 TUI
|
|
||||||
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
|
|
||||||
|
|
||||||
# Released legacy TUI behavior reference
|
|
||||||
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
|
|
||||||
|
|
||||||
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
|
|
||||||
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
|
|
||||||
```
|
|
||||||
|
|
||||||
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
|
|
||||||
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
|
|
||||||
|
|
||||||
## Interactive debugging
|
|
||||||
|
|
||||||
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
|
|
||||||
- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server.
|
|
||||||
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
|
|
||||||
- Use a dedicated session name and do not reuse or kill an unrelated session.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
|
|
||||||
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
|
|
||||||
termctrl show opencode-v2-dev
|
|
||||||
```
|
|
||||||
|
|
||||||
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
|
|
||||||
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
termctrl send opencode-v2-dev 'text:example prompt' enter
|
|
||||||
termctrl send opencode-v2-dev ctrl-c
|
|
||||||
```
|
|
||||||
|
|
||||||
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
|
|
||||||
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
|
|
||||||
```
|
|
||||||
|
|
||||||
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
termctrl resize opencode-v2-dev --cols 100 --rows 30
|
|
||||||
termctrl show opencode-v2-dev
|
|
||||||
```
|
|
||||||
|
|
||||||
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
|
|
||||||
- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
|
|
||||||
- Always clean up the Terminal Control session when the check is complete:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
termctrl stop opencode-v2-dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Server/API debugging
|
|
||||||
|
|
||||||
- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI.
|
|
||||||
- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering.
|
|
||||||
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun dev api get /health
|
|
||||||
bun dev api get /openapi.json
|
|
||||||
bun dev api <operationId> --param key=value
|
|
||||||
```
|
|
||||||
|
|
||||||
- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`.
|
|
||||||
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
|
|
||||||
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
|
|
||||||
|
|
||||||
## Debugger
|
|
||||||
|
|
||||||
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
|
|
||||||
bun run --inspect=ws://localhost:6499/ src/index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches.
|
|
||||||
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
|
|
||||||
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
|
|
||||||
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.
|
|
||||||
@@ -31,11 +31,11 @@ function run(target) {
|
|||||||
|
|
||||||
const envPath = process.env.OPENCODE_BIN_PATH
|
const envPath = process.env.OPENCODE_BIN_PATH
|
||||||
const scriptDir = path.dirname(fs.realpathSync(__filename))
|
const scriptDir = path.dirname(fs.realpathSync(__filename))
|
||||||
const cached = path.join(scriptDir, ".opencode2")
|
const cached = path.join(scriptDir, ".lildax")
|
||||||
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
|
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
|
||||||
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
|
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
|
||||||
const base = "@opencode-ai/cli-" + platform + "-" + arch
|
const base = "@opencode-ai/cli-" + platform + "-" + arch
|
||||||
const binary = platform === "windows" ? "opencode2.exe" : "opencode2"
|
const binary = platform === "windows" ? "lildax.exe" : "lildax"
|
||||||
|
|
||||||
function supportsAvx2() {
|
function supportsAvx2() {
|
||||||
if (arch !== "x64") return false
|
if (arch !== "x64") return false
|
||||||
@@ -121,7 +121,7 @@ function findBinary(startDir) {
|
|||||||
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
|
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
console.error(
|
console.error(
|
||||||
"It seems that your package manager failed to install the right opencode2 CLI package. Try manually installing " +
|
"It seems that your package manager failed to install the right lildax CLI package. Try manually installing " +
|
||||||
names.map((name) => `"${name}"`).join(" or ") +
|
names.map((name) => `"${name}"`).join(" or ") +
|
||||||
" package",
|
" package",
|
||||||
)
|
)
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode2": "./bin/opencode2.cjs"
|
"lildax": "./bin/lildax.cjs"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"bin"
|
"bin"
|
||||||
@@ -17,7 +17,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@opencode-ai/client": "workspace:*",
|
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/server": "workspace:*",
|
"@opencode-ai/server": "workspace:*",
|
||||||
@@ -26,15 +25,12 @@
|
|||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"jsonc-parser": "3.3.1",
|
|
||||||
"semver": "catalog:",
|
|
||||||
"solid-js": "catalog:"
|
"solid-js": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/semver": "catalog:",
|
|
||||||
"@typescript/native-preview": "catalog:"
|
"@typescript/native-preview": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import pkg from "../package.json"
|
|||||||
import { modelsData } from "./generate"
|
import { modelsData } from "./generate"
|
||||||
|
|
||||||
const dir = path.resolve(import.meta.dirname, "..")
|
const dir = path.resolve(import.meta.dirname, "..")
|
||||||
const binary = "opencode2"
|
const binary = "lildax"
|
||||||
process.chdir(dir)
|
process.chdir(dir)
|
||||||
|
|
||||||
await rm("dist", { recursive: true, force: true })
|
await rm("dist", { recursive: true, force: true })
|
||||||
|
|||||||
@@ -25,15 +25,14 @@ for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" }
|
|||||||
}
|
}
|
||||||
console.log("binaries", binaries)
|
console.log("binaries", binaries)
|
||||||
const version = Object.values(binaries)[0]
|
const version = Object.values(binaries)[0]
|
||||||
const name = pkg.name
|
|
||||||
|
|
||||||
await $`mkdir -p ./dist/${name}/bin`
|
await $`mkdir -p ./dist/${pkg.name}/bin`
|
||||||
await $`cp ./bin/opencode2.cjs ./dist/${name}/bin/opencode2`
|
await $`cp ./bin/lildax.cjs ./dist/${pkg.name}/bin/lildax`
|
||||||
await Bun.file(`./dist/${name}/package.json`).write(
|
await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
name,
|
name: pkg.name,
|
||||||
bin: { opencode2: "./bin/opencode2" },
|
bin: { lildax: "./bin/lildax" },
|
||||||
version,
|
version,
|
||||||
license: pkg.license,
|
license: pkg.license,
|
||||||
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
|
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
|
||||||
@@ -51,4 +50,4 @@ await Promise.all(
|
|||||||
publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version),
|
publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
await publish(`./dist/${name}`, name, version)
|
await publish(`./dist/${pkg.name}`, pkg.name, version)
|
||||||
|
|||||||
@@ -5,16 +5,6 @@ declare const OPENCODE_CLI_NAME: string | undefined
|
|||||||
|
|
||||||
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
||||||
description: "OpenCode 2.0 preview command line interface",
|
description: "OpenCode 2.0 preview command line interface",
|
||||||
params: {
|
|
||||||
directory: Argument.string("directory").pipe(
|
|
||||||
Argument.withDescription("Directory to start OpenCode in"),
|
|
||||||
Argument.optional,
|
|
||||||
),
|
|
||||||
standalone: Flag.boolean("standalone").pipe(
|
|
||||||
Flag.withDescription("Run with a private server instead of the background service"),
|
|
||||||
Flag.withDefault(false),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
commands: [
|
commands: [
|
||||||
Spec.make("api", {
|
Spec.make("api", {
|
||||||
description: "Make a request to the running server",
|
description: "Make a request to the running server",
|
||||||
@@ -36,43 +26,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||||||
description: "Debugging and troubleshooting tools",
|
description: "Debugging and troubleshooting tools",
|
||||||
commands: [Spec.make("agents", { description: "List all agents" })],
|
commands: [Spec.make("agents", { description: "List all agents" })],
|
||||||
}),
|
}),
|
||||||
Spec.make("mcp", {
|
|
||||||
description: "Manage MCP (Model Context Protocol) servers",
|
|
||||||
commands: [
|
|
||||||
Spec.make("list", { description: "List configured MCP servers and their status" }),
|
|
||||||
Spec.make("add", {
|
|
||||||
description: "Add an MCP server to your configuration",
|
|
||||||
params: {
|
|
||||||
name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")),
|
|
||||||
command: Argument.string("command").pipe(
|
|
||||||
Argument.withDescription("Command and arguments for a local server, passed after --"),
|
|
||||||
Argument.variadic({ min: 0 }),
|
|
||||||
),
|
|
||||||
url: Flag.string("url").pipe(Flag.withDescription("URL for a remote MCP server"), Flag.optional),
|
|
||||||
header: Flag.keyValuePair("header").pipe(
|
|
||||||
Flag.withDescription("HTTP header for a remote server, as name=value"),
|
|
||||||
Flag.optional,
|
|
||||||
),
|
|
||||||
env: Flag.keyValuePair("env").pipe(
|
|
||||||
Flag.withDescription("Environment variable for a local server, as name=value"),
|
|
||||||
Flag.optional,
|
|
||||||
),
|
|
||||||
global: Flag.boolean("global").pipe(
|
|
||||||
Flag.withDescription("Write to the global config instead of the project config"),
|
|
||||||
Flag.withDefault(false),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Spec.make("auth", {
|
|
||||||
description: "Authenticate with an OAuth-capable remote MCP server",
|
|
||||||
params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
|
||||||
}),
|
|
||||||
Spec.make("logout", {
|
|
||||||
description: "Remove stored OAuth credentials for an MCP server",
|
|
||||||
params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
||||||
Spec.make("service", {
|
Spec.make("service", {
|
||||||
description: "Manage the background server",
|
description: "Manage the background server",
|
||||||
@@ -81,27 +34,18 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||||||
Spec.make("restart", { description: "Restart the background server" }),
|
Spec.make("restart", { description: "Restart the background server" }),
|
||||||
Spec.make("status", { description: "Show background server status" }),
|
Spec.make("status", { description: "Show background server status" }),
|
||||||
Spec.make("stop", { description: "Stop the background server" }),
|
Spec.make("stop", { description: "Stop the background server" }),
|
||||||
Spec.make("get", {
|
Spec.make("password", {
|
||||||
description: "Get service configuration",
|
description: "Get or set the server password",
|
||||||
params: { key: Argument.string("key").pipe(Argument.optional) },
|
params: { value: Argument.string("value").pipe(Argument.optional) },
|
||||||
}),
|
|
||||||
Spec.make("set", {
|
|
||||||
description: "Set service configuration",
|
|
||||||
params: { key: Argument.string("key"), value: Argument.string("value") },
|
|
||||||
}),
|
|
||||||
Spec.make("unset", {
|
|
||||||
description: "Unset service configuration",
|
|
||||||
params: { key: Argument.string("key") },
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
Spec.make("serve", {
|
Spec.make("serve", {
|
||||||
description: "Start the v2 API server",
|
description: "Start the v2 API server",
|
||||||
params: {
|
params: {
|
||||||
hostname: Flag.string("hostname").pipe(Flag.optional),
|
hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
|
||||||
port: Flag.integer("port").pipe(Flag.optional),
|
port: Flag.integer("port").pipe(Flag.optional),
|
||||||
service: Flag.boolean("service").pipe(Flag.withDefault(false)),
|
register: Flag.boolean("register").pipe(Flag.withDefault(false)),
|
||||||
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,27 +1,13 @@
|
|||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { Effect, Option } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Daemon } from "../../services/daemon"
|
import { Daemon } from "../../services/daemon"
|
||||||
import { Standalone } from "../../services/standalone"
|
|
||||||
import { Updater } from "../../services/updater"
|
|
||||||
|
|
||||||
export default Runtime.handler(Commands, (input) =>
|
export default Runtime.handler(Commands, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const directory = Option.getOrUndefined(input.directory)
|
|
||||||
if (directory !== undefined) process.chdir(directory)
|
|
||||||
const updater = yield* Updater.Service
|
|
||||||
yield* updater.check()
|
|
||||||
const daemon = yield* Daemon.Service
|
const daemon = yield* Daemon.Service
|
||||||
const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
|
const transport = yield* daemon.transport()
|
||||||
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
||||||
yield* runTui(
|
yield* runTui(transport)
|
||||||
transport,
|
|
||||||
input.standalone
|
|
||||||
? undefined
|
|
||||||
: async () => {
|
|
||||||
await Effect.runPromise(daemon.stop())
|
|
||||||
return Effect.runPromise(daemon.transport())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import { EOL } from "node:os"
|
|
||||||
import path from "node:path"
|
|
||||||
import { Effect, Option } from "effect"
|
|
||||||
import { applyEdits, modify } from "jsonc-parser"
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { Commands } from "../../commands"
|
|
||||||
import { Runtime } from "../../../framework/runtime"
|
|
||||||
|
|
||||||
export default Runtime.handler(
|
|
||||||
Commands.commands.mcp.commands.add,
|
|
||||||
Effect.fn("cli.mcp.add")(function* (input) {
|
|
||||||
const url = Option.getOrUndefined(input.url)
|
|
||||||
const headers = Option.getOrUndefined(input.header)
|
|
||||||
const environment = Option.getOrUndefined(input.env)
|
|
||||||
// The CLI framework strands `--` operands on the root command, so read the local server command
|
|
||||||
// straight from argv after `--`. This also lets the command carry its own flags (e.g. `npx -y`).
|
|
||||||
const dash = process.argv.indexOf("--")
|
|
||||||
const command = dash === -1 ? [...input.command] : process.argv.slice(dash + 1)
|
|
||||||
|
|
||||||
const hasCommand = command.length > 0
|
|
||||||
if (url && hasCommand)
|
|
||||||
return yield* Effect.fail(new Error("Provide either --url <url> or a command after --, not both"))
|
|
||||||
if (!url && !hasCommand) return yield* Effect.fail(new Error("Provide either --url <url> or a command after --"))
|
|
||||||
if (url && !URL.canParse(url)) return yield* Effect.fail(new Error(`Invalid URL: ${url}`))
|
|
||||||
if (url && environment) return yield* Effect.fail(new Error("--env is only valid for local MCP servers"))
|
|
||||||
if (hasCommand && headers) return yield* Effect.fail(new Error("--header is only valid for remote MCP servers"))
|
|
||||||
|
|
||||||
const server = url
|
|
||||||
? { type: "remote" as const, url, ...(headers ? { headers } : {}) }
|
|
||||||
: { type: "local" as const, command, ...(environment ? { environment } : {}) }
|
|
||||||
|
|
||||||
const configPath = yield* Effect.promise(() => resolveConfigPath(input.global ? Global.Path.config : process.cwd()))
|
|
||||||
yield* Effect.promise(() => write(configPath, input.name, server))
|
|
||||||
process.stdout.write(`MCP server "${input.name}" added to ${configPath}` + EOL)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
async function resolveConfigPath(directory: string) {
|
|
||||||
const candidates = [
|
|
||||||
path.join(directory, "opencode.json"),
|
|
||||||
path.join(directory, "opencode.jsonc"),
|
|
||||||
path.join(directory, ".opencode", "opencode.json"),
|
|
||||||
path.join(directory, ".opencode", "opencode.jsonc"),
|
|
||||||
]
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (await Bun.file(candidate).exists()) return candidate
|
|
||||||
}
|
|
||||||
return candidates[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function write(configPath: string, name: string, server: unknown) {
|
|
||||||
const file = Bun.file(configPath)
|
|
||||||
const text = (await file.exists()) ? await file.text() : "{}"
|
|
||||||
const edits = modify(text, ["mcp", "servers", name], server, {
|
|
||||||
formattingOptions: { tabSize: 2, insertSpaces: true },
|
|
||||||
})
|
|
||||||
await Bun.write(configPath, applyEdits(text, edits))
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { EOL } from "node:os"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import type { IntegrationAttemptStatus, IntegrationOAuthMethod, OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { Commands } from "../../commands"
|
|
||||||
import { Runtime } from "../../../framework/runtime"
|
|
||||||
import { Daemon } from "../../../services/daemon"
|
|
||||||
import { resolveIntegration } from "./resolve"
|
|
||||||
|
|
||||||
const location = { directory: process.cwd() }
|
|
||||||
|
|
||||||
export default Runtime.handler(
|
|
||||||
Commands.commands.mcp.commands.auth,
|
|
||||||
Effect.fn("cli.mcp.auth")(function* (input) {
|
|
||||||
const daemon = yield* Daemon.Service
|
|
||||||
const client = yield* daemon.client()
|
|
||||||
|
|
||||||
const integration = yield* resolveIntegration(client, input.name, location)
|
|
||||||
if (!integration)
|
|
||||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
|
|
||||||
const method = integration.methods.find(
|
|
||||||
(candidate): candidate is IntegrationOAuthMethod => candidate.type === "oauth",
|
|
||||||
)
|
|
||||||
if (!method)
|
|
||||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
|
|
||||||
|
|
||||||
const started = yield* Effect.promise(() =>
|
|
||||||
client.v2.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
|
|
||||||
)
|
|
||||||
const attempt = started.data?.data
|
|
||||||
if (!attempt) return yield* Effect.fail(new Error(started.error?.message ?? "Failed to start OAuth attempt"))
|
|
||||||
if (attempt.mode === "code")
|
|
||||||
return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support"))
|
|
||||||
|
|
||||||
process.stdout.write(attempt.instructions + EOL + attempt.url + EOL)
|
|
||||||
|
|
||||||
const result = yield* poll(client, attempt.attemptID)
|
|
||||||
if (result.status === "complete") {
|
|
||||||
process.stdout.write(`Authenticated with ${input.name}` + EOL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const reason = result.status === "failed" ? `: ${result.message}` : ""
|
|
||||||
return yield* Effect.fail(new Error(`Authentication ${result.status}${reason}`))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const poll = (
|
|
||||||
client: OpencodeClient,
|
|
||||||
attemptID: string,
|
|
||||||
): Effect.Effect<Exclude<IntegrationAttemptStatus, { status: "pending" }>> =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const response = yield* Effect.promise(() => client.v2.integration.attempt.status({ attemptID, location }))
|
|
||||||
const status = response.data?.data
|
|
||||||
if (!status || status.status === "pending") {
|
|
||||||
yield* Effect.sleep("1 second")
|
|
||||||
return yield* poll(client, attemptID)
|
|
||||||
}
|
|
||||||
return status
|
|
||||||
})
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { EOL } from "node:os"
|
|
||||||
import * as Effect from "effect/Effect"
|
|
||||||
import type { McpServer } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { Commands } from "../../commands"
|
|
||||||
import { Runtime } from "../../../framework/runtime"
|
|
||||||
import { Daemon } from "../../../services/daemon"
|
|
||||||
|
|
||||||
export default Runtime.handler(
|
|
||||||
Commands.commands.mcp.commands.list,
|
|
||||||
Effect.fn("cli.mcp.list")(function* () {
|
|
||||||
const daemon = yield* Daemon.Service
|
|
||||||
const client = yield* daemon.client()
|
|
||||||
const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } }))
|
|
||||||
const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name))
|
|
||||||
if (servers.length === 0) {
|
|
||||||
process.stdout.write("No MCP servers configured" + EOL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const width = Math.max(...servers.map((server) => server.name.length))
|
|
||||||
const lines = servers.map(
|
|
||||||
(server) => `${icon(server.status)} ${server.name.padEnd(width)} ${describe(server.status)}`,
|
|
||||||
)
|
|
||||||
process.stdout.write(lines.join(EOL) + EOL)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
function icon(status: McpServer["status"]) {
|
|
||||||
switch (status.status) {
|
|
||||||
case "connected":
|
|
||||||
return "✓"
|
|
||||||
case "needs_auth":
|
|
||||||
return "⚠"
|
|
||||||
case "failed":
|
|
||||||
case "needs_client_registration":
|
|
||||||
return "✗"
|
|
||||||
default:
|
|
||||||
return "○"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function describe(status: McpServer["status"]) {
|
|
||||||
switch (status.status) {
|
|
||||||
case "needs_auth":
|
|
||||||
return "needs authentication"
|
|
||||||
case "needs_client_registration":
|
|
||||||
return `needs client registration: ${status.error}`
|
|
||||||
case "failed":
|
|
||||||
return `failed: ${status.error}`
|
|
||||||
default:
|
|
||||||
return status.status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { EOL } from "node:os"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { Commands } from "../../commands"
|
|
||||||
import { Runtime } from "../../../framework/runtime"
|
|
||||||
import { Daemon } from "../../../services/daemon"
|
|
||||||
import { resolveIntegration } from "./resolve"
|
|
||||||
|
|
||||||
const location = { directory: process.cwd() }
|
|
||||||
|
|
||||||
export default Runtime.handler(
|
|
||||||
Commands.commands.mcp.commands.logout,
|
|
||||||
Effect.fn("cli.mcp.logout")(function* (input) {
|
|
||||||
const daemon = yield* Daemon.Service
|
|
||||||
const client = yield* daemon.client()
|
|
||||||
|
|
||||||
const integration = yield* resolveIntegration(client, input.name, location)
|
|
||||||
if (!integration) {
|
|
||||||
process.stdout.write(`No stored credentials for ${input.name}` + EOL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials = integration.connections.filter((connection) => connection.type === "credential")
|
|
||||||
if (credentials.length === 0) {
|
|
||||||
process.stdout.write(`No stored credentials for ${input.name}` + EOL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Effect.forEach(
|
|
||||||
credentials,
|
|
||||||
(connection) => Effect.promise(() => client.v2.credential.remove({ credentialID: connection.id, location })),
|
|
||||||
{ discard: true },
|
|
||||||
)
|
|
||||||
process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Effect } from "effect"
|
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
|
|
||||||
// Resolve through the MCP-owned integrationID rather than matching integration names: the shared
|
|
||||||
// integration registry also holds provider/plugin integrations, whose names could collide with a server.
|
|
||||||
// Fails when the server is unknown; returns undefined when the server has no integration (e.g. a local
|
|
||||||
// or anonymous server), leaving that case for the caller to interpret.
|
|
||||||
export const resolveIntegration = (client: OpencodeClient, name: string, location: { directory: string }) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const servers = yield* Effect.promise(() => client.v2.mcp.list({ location }))
|
|
||||||
const server = (servers.data?.data ?? []).find((entry) => entry.name === name)
|
|
||||||
if (!server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`))
|
|
||||||
const integrationID = server.integrationID
|
|
||||||
if (!integrationID) return undefined
|
|
||||||
const found = yield* Effect.promise(() => client.v2.integration.get({ integrationID, location }))
|
|
||||||
return found.data?.data
|
|
||||||
})
|
|
||||||
@@ -1,18 +1,14 @@
|
|||||||
import { NodeHttpServer } from "@effect/platform-node"
|
import { NodeHttpServer } from "@effect/platform-node"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||||
import { Context, Layer, Option, Schedule } from "effect"
|
import { Context, Layer, Option } from "effect"
|
||||||
import * as Effect from "effect/Effect"
|
import * as Effect from "effect/Effect"
|
||||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||||
import { createServer } from "node:http"
|
import { createServer } from "node:http"
|
||||||
import { createRoutes } from "@opencode-ai/server/routes"
|
import { createRoutes } from "@opencode-ai/server/routes"
|
||||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { Daemon } from "../../services/daemon"
|
import { Daemon } from "../../services/daemon"
|
||||||
import { Updater } from "../../services/updater"
|
|
||||||
import { randomBytes } from "crypto"
|
|
||||||
|
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.serve,
|
Commands.commands.serve,
|
||||||
@@ -20,53 +16,15 @@ export default Runtime.handler(
|
|||||||
return yield* Effect.scoped(
|
return yield* Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const daemon = yield* Daemon.Service
|
const daemon = yield* Daemon.Service
|
||||||
const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD
|
const address = yield* listen(input.hostname, input.port, yield* daemon.password())
|
||||||
if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD
|
if (input.register) yield* daemon.register(address)
|
||||||
const config = input.service ? yield* daemon.config() : {}
|
console.log(`server listening on ${HttpServer.formatAddress(address)}`)
|
||||||
const password = input.service
|
return yield* Effect.never
|
||||||
? yield* daemon.password()
|
}),
|
||||||
: standalonePassword || randomBytes(32).toString("base64url")
|
|
||||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
|
||||||
const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1"
|
|
||||||
const port = Option.isSome(input.port)
|
|
||||||
? input.port
|
|
||||||
: config.port === undefined
|
|
||||||
? Option.none<number>()
|
|
||||||
: Option.some(config.port)
|
|
||||||
const address = yield* listen(hostname, port, password)
|
|
||||||
yield* Effect.tryPromise(() =>
|
|
||||||
createOpencodeClient({
|
|
||||||
baseUrl: HttpServer.formatAddress(address),
|
|
||||||
headers: ServerAuth.headers({ password }),
|
|
||||||
}).v2.health.get({}),
|
|
||||||
)
|
|
||||||
if (input.service) yield* daemon.register(address)
|
|
||||||
const url = HttpServer.formatAddress(address)
|
|
||||||
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
|
|
||||||
if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`)
|
|
||||||
const updater = yield* Updater.Service
|
|
||||||
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
|
|
||||||
return yield* (input.stdio ? waitForStdinClose() : Effect.never)
|
|
||||||
}).pipe(Effect.annotateLogs({ role: "server" })),
|
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
function waitForStdinClose() {
|
|
||||||
return Effect.callback<void>((resume) => {
|
|
||||||
const close = () => resume(Effect.void)
|
|
||||||
process.stdin.once("end", close)
|
|
||||||
process.stdin.once("close", close)
|
|
||||||
process.stdin.resume()
|
|
||||||
if (process.stdin.readableEnded || process.stdin.destroyed) close()
|
|
||||||
return Effect.sync(() => {
|
|
||||||
process.stdin.off("end", close)
|
|
||||||
process.stdin.off("close", close)
|
|
||||||
process.stdin.pause()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function listen(hostname: string, port: Option.Option<number>, password: string) {
|
function listen(hostname: string, port: Option.Option<number>, password: string) {
|
||||||
if (Option.isSome(port)) return bind(hostname, port.value, password)
|
if (Option.isSome(port)) return bind(hostname, port.value, password)
|
||||||
const next = (port: number): ReturnType<typeof bind> =>
|
const next = (port: number): ReturnType<typeof bind> =>
|
||||||
@@ -77,15 +35,11 @@ function listen(hostname: string, port: Option.Option<number>, password: string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bind(hostname: string, port: number, password: string) {
|
function bind(hostname: string, port: number, password: string) {
|
||||||
const server = createServer()
|
|
||||||
return Layer.build(
|
return Layer.build(
|
||||||
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
|
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
|
||||||
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
|
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
|
||||||
Layer.provide(Credential.defaultLayer),
|
Layer.provide(Credential.defaultLayer),
|
||||||
Layer.provide(PermissionSaved.defaultLayer),
|
Layer.provide(PermissionSaved.defaultLayer),
|
||||||
),
|
),
|
||||||
).pipe(
|
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
|
||||||
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),
|
|
||||||
Effect.map((context) => Context.get(context, HttpServer.HttpServer).address),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -6,9 +6,11 @@ import { Runtime } from "../../../framework/runtime"
|
|||||||
import { Daemon } from "../../../services/daemon"
|
import { Daemon } from "../../../services/daemon"
|
||||||
|
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.service.commands.get,
|
Commands.commands.service.commands.password,
|
||||||
Effect.fn("cli.service.get")(function* (input) {
|
Effect.fn("cli.service.password")(function* (input) {
|
||||||
const daemon = yield* Daemon.Service
|
const daemon = yield* Daemon.Service
|
||||||
process.stdout.write((yield* daemon.get(Option.getOrUndefined(input.key))) + EOL)
|
const value = Option.getOrUndefined(input.value)
|
||||||
|
if (value !== undefined) yield* daemon.stop()
|
||||||
|
process.stdout.write((yield* daemon.password(value)) + EOL)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import * as Effect from "effect/Effect"
|
|
||||||
import { Commands } from "../../commands"
|
|
||||||
import { Runtime } from "../../../framework/runtime"
|
|
||||||
import { Daemon } from "../../../services/daemon"
|
|
||||||
|
|
||||||
export default Runtime.handler(
|
|
||||||
Commands.commands.service.commands.set,
|
|
||||||
Effect.fn("cli.service.set")(function* (input) {
|
|
||||||
yield* (yield* Daemon.Service).set(input.key, input.value)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
@@ -8,6 +8,6 @@ export default Runtime.handler(
|
|||||||
Commands.commands.service.commands.status,
|
Commands.commands.service.commands.status,
|
||||||
Effect.fn("cli.service.status")(function* () {
|
Effect.fn("cli.service.status")(function* () {
|
||||||
const url = yield* (yield* Daemon.Service).status()
|
const url = yield* (yield* Daemon.Service).status()
|
||||||
process.stdout.write((url ? url : "stopped") + EOL)
|
process.stdout.write((url ? `running ${url}` : "stopped") + EOL)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import * as Effect from "effect/Effect"
|
|
||||||
import { Commands } from "../../commands"
|
|
||||||
import { Runtime } from "../../../framework/runtime"
|
|
||||||
import { Daemon } from "../../../services/daemon"
|
|
||||||
|
|
||||||
export default Runtime.handler(
|
|
||||||
Commands.commands.service.commands.unset,
|
|
||||||
Effect.fn("cli.service.unset")(function* (input) {
|
|
||||||
yield* (yield* Daemon.Service).unset(input.key)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
@@ -2,8 +2,6 @@ import * as Effect from "effect/Effect"
|
|||||||
import * as Command from "effect/unstable/cli/Command"
|
import * as Command from "effect/unstable/cli/Command"
|
||||||
import { Spec } from "./spec"
|
import { Spec } from "./spec"
|
||||||
import { Daemon } from "../services/daemon"
|
import { Daemon } from "../services/daemon"
|
||||||
import { Updater } from "../services/updater"
|
|
||||||
import { Scope } from "effect"
|
|
||||||
|
|
||||||
export type Input<Value> =
|
export type Input<Value> =
|
||||||
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
||||||
@@ -12,11 +10,11 @@ export type Input<Value> =
|
|||||||
? Input
|
? Input
|
||||||
: never
|
: never
|
||||||
|
|
||||||
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Updater.Service | Scope.Scope>
|
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service>
|
||||||
type Loader<Node extends Spec.Any> = () => Promise<{
|
type Loader<Node extends Spec.Any> = () => Promise<{
|
||||||
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Updater.Service | Scope.Scope>
|
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service>
|
||||||
}>
|
}>
|
||||||
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Updater.Service | Scope.Scope>
|
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
|
||||||
|
|
||||||
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
||||||
? Loader<Node>
|
? Loader<Node>
|
||||||
|
|||||||
@@ -2,21 +2,10 @@
|
|||||||
|
|
||||||
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
|
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
|
||||||
import * as NodeServices from "@effect/platform-node/NodeServices"
|
import * as NodeServices from "@effect/platform-node/NodeServices"
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
|
||||||
import * as Effect from "effect/Effect"
|
import * as Effect from "effect/Effect"
|
||||||
import { Layer, Logger, References } from "effect"
|
|
||||||
import { Commands } from "./commands/commands"
|
import { Commands } from "./commands/commands"
|
||||||
import { Runtime } from "./framework/runtime"
|
import { Runtime } from "./framework/runtime"
|
||||||
import { Daemon } from "./services/daemon"
|
import { Daemon } from "./services/daemon"
|
||||||
import { Logging } from "@opencode-ai/core/observability/logging"
|
|
||||||
import { Updater } from "./services/updater"
|
|
||||||
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
|
||||||
|
|
||||||
const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
|
|
||||||
Layer.provide(NodeFileSystem.layer),
|
|
||||||
Layer.orDie,
|
|
||||||
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
|
|
||||||
)
|
|
||||||
|
|
||||||
const Handlers = Runtime.handlers(Commands, {
|
const Handlers = Runtime.handlers(Commands, {
|
||||||
$: () => import("./commands/handlers/default"),
|
$: () => import("./commands/handlers/default"),
|
||||||
@@ -24,33 +13,20 @@ const Handlers = Runtime.handlers(Commands, {
|
|||||||
debug: {
|
debug: {
|
||||||
agents: () => import("./commands/handlers/debug/agents"),
|
agents: () => import("./commands/handlers/debug/agents"),
|
||||||
},
|
},
|
||||||
mcp: {
|
|
||||||
list: () => import("./commands/handlers/mcp/list"),
|
|
||||||
add: () => import("./commands/handlers/mcp/add"),
|
|
||||||
auth: () => import("./commands/handlers/mcp/auth"),
|
|
||||||
logout: () => import("./commands/handlers/mcp/logout"),
|
|
||||||
},
|
|
||||||
migrate: () => import("./commands/handlers/migrate"),
|
migrate: () => import("./commands/handlers/migrate"),
|
||||||
service: {
|
service: {
|
||||||
start: () => import("./commands/handlers/service/start"),
|
start: () => import("./commands/handlers/service/start"),
|
||||||
restart: () => import("./commands/handlers/service/restart"),
|
restart: () => import("./commands/handlers/service/restart"),
|
||||||
status: () => import("./commands/handlers/service/status"),
|
status: () => import("./commands/handlers/service/status"),
|
||||||
stop: () => import("./commands/handlers/service/stop"),
|
stop: () => import("./commands/handlers/service/stop"),
|
||||||
get: () => import("./commands/handlers/service/get"),
|
password: () => import("./commands/handlers/service/password"),
|
||||||
set: () => import("./commands/handlers/service/set"),
|
|
||||||
unset: () => import("./commands/handlers/service/unset"),
|
|
||||||
},
|
},
|
||||||
serve: () => import("./commands/handlers/serve"),
|
serve: () => import("./commands/handlers/serve"),
|
||||||
})
|
})
|
||||||
|
|
||||||
Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe(
|
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
|
||||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
|
||||||
Effect.annotateLogs({ role: "cli" }),
|
|
||||||
Effect.provide(Daemon.defaultLayer),
|
Effect.provide(Daemon.defaultLayer),
|
||||||
Effect.provide(Updater.defaultLayer),
|
|
||||||
Effect.provide(LoggingLayer),
|
|
||||||
Effect.provide(NodeServices.layer),
|
Effect.provide(NodeServices.layer),
|
||||||
Effect.scoped,
|
Effect.scoped,
|
||||||
Effect.tap(() => Effect.sync(() => process.exit(0))),
|
|
||||||
NodeRuntime.runMain,
|
NodeRuntime.runMain,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
import { ServerAuth } from "@opencode-ai/server/auth"
|
||||||
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
|
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
|
||||||
@@ -15,10 +15,6 @@ export interface Interface {
|
|||||||
readonly status: () => Effect.Effect<string | undefined>
|
readonly status: () => Effect.Effect<string | undefined>
|
||||||
readonly stop: () => Effect.Effect<void, unknown>
|
readonly stop: () => Effect.Effect<void, unknown>
|
||||||
readonly password: (value?: string) => Effect.Effect<string, unknown>
|
readonly password: (value?: string) => Effect.Effect<string, unknown>
|
||||||
readonly config: () => Effect.Effect<ServiceConfig, unknown>
|
|
||||||
readonly get: (key?: string) => Effect.Effect<string, unknown>
|
|
||||||
readonly set: (key: string, value: string) => Effect.Effect<void, unknown>
|
|
||||||
readonly unset: (key: string) => Effect.Effect<void, unknown>
|
|
||||||
readonly register: (address: HttpServer.Address) => Effect.Effect<void, unknown, Scope.Scope>
|
readonly register: (address: HttpServer.Address) => Effect.Effect<void, unknown, Scope.Scope>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,22 +28,6 @@ const Registration = Schema.Struct({
|
|||||||
})
|
})
|
||||||
type Registration = typeof Registration.Type
|
type Registration = typeof Registration.Type
|
||||||
|
|
||||||
const ServiceConfig = Schema.Struct({
|
|
||||||
hostname: Schema.optional(Schema.String),
|
|
||||||
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
|
|
||||||
password: Schema.optional(Schema.String),
|
|
||||||
autostart: Schema.optional(Schema.Boolean),
|
|
||||||
})
|
|
||||||
export type ServiceConfig = typeof ServiceConfig.Type
|
|
||||||
|
|
||||||
const serviceConfigKeys = ["hostname", "port", "password", "autostart"] as const
|
|
||||||
type ServiceConfigKey = (typeof serviceConfigKeys)[number]
|
|
||||||
|
|
||||||
function serviceConfigKey(key: string): ServiceConfigKey {
|
|
||||||
if (serviceConfigKeys.includes(key as ServiceConfigKey)) return key as ServiceConfigKey
|
|
||||||
throw new Error(`Unknown service config key: ${key}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameRegistration(left: Registration, right: Registration) {
|
function sameRegistration(left: Registration, right: Registration) {
|
||||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||||
}
|
}
|
||||||
@@ -56,115 +36,23 @@ export const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
const global = yield* Global.Service
|
const directory = Global.Path.state
|
||||||
const directory = global.state
|
const file = path.join(directory, "server.json")
|
||||||
const filename = InstallationChannel === "local" ? "service-local.json" : "service.json"
|
const passwordFile = path.join(directory, "password")
|
||||||
const file = path.join(directory, filename)
|
|
||||||
const configFile = path.join(global.config, filename)
|
|
||||||
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
|
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
|
||||||
const decodeServiceConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(ServiceConfig))
|
|
||||||
|
|
||||||
const config = Effect.fn("cli.daemon.config")(function* () {
|
|
||||||
return yield* fs.readFileString(configFile).pipe(
|
|
||||||
Effect.flatMap(decodeServiceConfig),
|
|
||||||
Effect.catch(() => Effect.succeed({} as ServiceConfig)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const writeConfig = Effect.fn("cli.daemon.writeConfig")(function* (value: ServiceConfig) {
|
|
||||||
const temp = configFile + ".tmp"
|
|
||||||
yield* fs.makeDirectory(path.dirname(configFile), { recursive: true })
|
|
||||||
yield* fs.writeFileString(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 })
|
|
||||||
yield* fs.rename(temp, configFile)
|
|
||||||
})
|
|
||||||
|
|
||||||
const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
|
const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
|
||||||
const existing = yield* config()
|
const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
if (value === undefined && existing.password) return existing.password
|
if (value === undefined && existing) return existing
|
||||||
const next = value ?? randomBytes(32).toString("base64url")
|
|
||||||
|
|
||||||
// Keep one private credential across server restarts so discovered clients
|
// Keep one private credential across server restarts so discovered clients
|
||||||
// can reconnect without exposing a password flag or environment variable.
|
// can reconnect without exposing a password flag or environment variable.
|
||||||
yield* writeConfig({ ...existing, password: next })
|
const generated = value ?? randomBytes(32).toString("base64url")
|
||||||
return next
|
const temp = passwordFile + ".tmp"
|
||||||
})
|
yield* fs.makeDirectory(directory, { recursive: true })
|
||||||
|
yield* fs.writeFileString(temp, generated, { mode: 0o600 })
|
||||||
const get = Effect.fn("cli.daemon.get")(function* (key?: string) {
|
yield* fs.rename(temp, passwordFile)
|
||||||
if (key === undefined) {
|
return generated
|
||||||
const { password: _password, ...safe } = yield* config()
|
|
||||||
return JSON.stringify(safe, null, 2)
|
|
||||||
}
|
|
||||||
switch (serviceConfigKey(key)) {
|
|
||||||
case "hostname": {
|
|
||||||
return (yield* config()).hostname ?? ""
|
|
||||||
}
|
|
||||||
case "port": {
|
|
||||||
const port = (yield* config()).port
|
|
||||||
return port === undefined ? "" : String(port)
|
|
||||||
}
|
|
||||||
case "password": {
|
|
||||||
return yield* password()
|
|
||||||
}
|
|
||||||
case "autostart": {
|
|
||||||
const autostart = (yield* config()).autostart
|
|
||||||
return autostart === undefined ? "" : String(autostart)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const set = Effect.fn("cli.daemon.set")(function* (key: string, value: string) {
|
|
||||||
switch (serviceConfigKey(key)) {
|
|
||||||
case "hostname": {
|
|
||||||
yield* stop()
|
|
||||||
yield* writeConfig({ ...(yield* config()), hostname: value })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "port": {
|
|
||||||
const port = Number(value)
|
|
||||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("Port must be between 1 and 65535")
|
|
||||||
yield* stop()
|
|
||||||
yield* writeConfig({ ...(yield* config()), port })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "password": {
|
|
||||||
yield* stop()
|
|
||||||
yield* password(value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "autostart": {
|
|
||||||
if (value !== "true" && value !== "false") throw new Error("Autostart must be true or false")
|
|
||||||
yield* writeConfig({ ...(yield* config()), autostart: value === "true" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const unset = Effect.fn("cli.daemon.unset")(function* (key: string) {
|
|
||||||
switch (serviceConfigKey(key)) {
|
|
||||||
case "hostname": {
|
|
||||||
yield* stop()
|
|
||||||
const { hostname: _hostname, ...next } = yield* config()
|
|
||||||
yield* writeConfig(next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "port": {
|
|
||||||
yield* stop()
|
|
||||||
const { port: _port, ...next } = yield* config()
|
|
||||||
yield* writeConfig(next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "password": {
|
|
||||||
yield* stop()
|
|
||||||
const { password: _password, ...next } = yield* config()
|
|
||||||
yield* writeConfig(next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "autostart": {
|
|
||||||
const { autostart: _autostart, ...next } = yield* config()
|
|
||||||
yield* writeConfig(next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const registration = Effect.fnUntraced(function* () {
|
const registration = Effect.fnUntraced(function* () {
|
||||||
@@ -183,16 +71,6 @@ export const layer = Layer.effect(
|
|||||||
return yield* Effect.fail(new Error("Registered server is not healthy"))
|
return yield* Effect.fail(new Error("Registered server is not healthy"))
|
||||||
})
|
})
|
||||||
|
|
||||||
const remoteTransport = Effect.fn("cli.daemon.remoteTransport")(function* (input: ServiceConfig) {
|
|
||||||
const url = serviceURL(input)
|
|
||||||
const headers = ServerAuth.headers({ password: input.password })
|
|
||||||
const response = yield* Effect.tryPromise(() =>
|
|
||||||
createOpencodeClient({ baseUrl: url, headers }).v2.health.get({ signal: AbortSignal.timeout(2_000) }),
|
|
||||||
)
|
|
||||||
if (response.data?.healthy === true) return { url, headers }
|
|
||||||
return yield* Effect.fail(new Error(`Server is not healthy: ${url}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
const compatible = Effect.fnUntraced(function* () {
|
const compatible = Effect.fnUntraced(function* () {
|
||||||
const info = yield* healthy()
|
const info = yield* healthy()
|
||||||
if (info.version === InstallationVersion) return info
|
if (info.version === InstallationVersion) return info
|
||||||
@@ -233,7 +111,7 @@ export const layer = Layer.effect(
|
|||||||
const existing = yield* healthy().pipe(Effect.option)
|
const existing = yield* healthy().pipe(Effect.option)
|
||||||
const found = Option.getOrUndefined(existing)
|
const found = Option.getOrUndefined(existing)
|
||||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
||||||
if (found?.version === InstallationVersion) return found.url
|
if (found?.version === InstallationVersion && compiled) return found.url
|
||||||
if (found) yield* stopProcess(found).pipe(Effect.ignore)
|
if (found) yield* stopProcess(found).pipe(Effect.ignore)
|
||||||
|
|
||||||
const entrypoint = compiled ? undefined : process.argv[1]
|
const entrypoint = compiled ? undefined : process.argv[1]
|
||||||
@@ -241,7 +119,7 @@ export const layer = Layer.effect(
|
|||||||
return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
|
return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
|
||||||
yield* Effect.try({
|
yield* Effect.try({
|
||||||
try: () => {
|
try: () => {
|
||||||
spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--service"], {
|
spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
}).unref()
|
}).unref()
|
||||||
@@ -257,8 +135,6 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const transport = Effect.fn("cli.daemon.transport")(function* () {
|
const transport = Effect.fn("cli.daemon.transport")(function* () {
|
||||||
const current = yield* config()
|
|
||||||
if (current.autostart === false) return yield* remoteTransport(current)
|
|
||||||
return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) }
|
return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -309,17 +185,10 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ client, transport, start, status, stop, password, config, get, set, unset, register })
|
return Service.of({ client, transport, start, status, stop, password, register })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Global.defaultLayer))
|
export const defaultLayer = layer
|
||||||
|
|
||||||
function serviceURL(config: ServiceConfig) {
|
|
||||||
const hostname = config.hostname ?? "127.0.0.1"
|
|
||||||
const result = new URL(`http://${hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname}`)
|
|
||||||
result.port = String(config.port ?? 4096)
|
|
||||||
return result.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export * as Daemon from "./daemon"
|
export * as Daemon from "./daemon"
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|
||||||
import { Effect, Schema, Stream } from "effect"
|
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
||||||
import { randomBytes } from "node:crypto"
|
|
||||||
import path from "node:path"
|
|
||||||
|
|
||||||
const Ready = Schema.Struct({ url: Schema.String })
|
|
||||||
const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
|
|
||||||
|
|
||||||
function command(password: string) {
|
|
||||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
|
||||||
const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
|
|
||||||
if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
|
|
||||||
return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--stdio", "--port", "0"], {
|
|
||||||
cwd: process.cwd(),
|
|
||||||
env: { OPENCODE_SERVER_PASSWORD: password },
|
|
||||||
extendEnv: true,
|
|
||||||
// The server treats EOF on this pipe as the end of its ownership lease.
|
|
||||||
// The OS closes it even when the TUI is killed before Effect finalizers run.
|
|
||||||
stdin: "pipe",
|
|
||||||
stderr: "ignore",
|
|
||||||
killSignal: "SIGTERM",
|
|
||||||
forceKillAfter: "3 seconds",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const transport = Effect.fn("cli.standalone.transport")(
|
|
||||||
function* () {
|
|
||||||
const password = randomBytes(32).toString("base64url")
|
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
const proc = yield* spawner.spawn(command(password))
|
|
||||||
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
|
|
||||||
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
|
|
||||||
const ready = yield* Effect.tryPromise(() => decodeReady(output))
|
|
||||||
return { url: ready.url, headers: ServerAuth.headers({ password }), pid: proc.pid }
|
|
||||||
},
|
|
||||||
Effect.provide(CrossSpawnSpawner.defaultLayer),
|
|
||||||
)
|
|
||||||
|
|
||||||
export * as Standalone from "./standalone"
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { action, decodePolicy } from "./updater"
|
|
||||||
|
|
||||||
describe("updater", () => {
|
|
||||||
test("reads autoupdate from JSONC", () => {
|
|
||||||
expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify")
|
|
||||||
expect(decodePolicy('{ "autoupdate": false }')).toBe(false)
|
|
||||||
expect(decodePolicy('{ "autoupdate": "invalid" }')).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("automatically updates patches and minors", () => {
|
|
||||||
expect(action("1.2.3", "1.2.4", true)).toBe("upgrade")
|
|
||||||
expect(action("1.2.3", "1.3.0", true)).toBe("upgrade")
|
|
||||||
expect(action("1.2.3", "1.2.4", "notify")).toBe("upgrade")
|
|
||||||
expect(action("1.2.3", "1.3.0", "notify")).toBe("upgrade")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("skips when autoupdate is disabled", () => {
|
|
||||||
expect(action("1.2.3", "1.2.4", false)).toBe("none")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("never automatically updates majors", () => {
|
|
||||||
expect(action("1.2.3", "2.0.0", true)).toBe("none")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("reports up-to-date only when versions match", () => {
|
|
||||||
expect(action("1.2.3", "1.2.3", true)).toBe("none")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("upgrades when latest is lower (rollback)", () => {
|
|
||||||
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
|
||||||
import { AppProcess } from "@opencode-ai/core/process"
|
|
||||||
import {
|
|
||||||
InstallationChannel,
|
|
||||||
InstallationLocal,
|
|
||||||
InstallationVersion,
|
|
||||||
} from "@opencode-ai/core/installation/version"
|
|
||||||
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
|
||||||
import { parse, type ParseError } from "jsonc-parser"
|
|
||||||
import path from "node:path"
|
|
||||||
import semver from "semver"
|
|
||||||
|
|
||||||
export type Policy = boolean | "notify"
|
|
||||||
export type Action = "none" | "upgrade"
|
|
||||||
type Method = "npm" | "pnpm" | "bun" | "yarn"
|
|
||||||
|
|
||||||
const packageName = "@opencode-ai/cli"
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly check: () => Effect.Effect<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
|
||||||
|
|
||||||
export function decodePolicy(text: string): Policy | undefined {
|
|
||||||
// The CLI only projects this host-level preference instead of initializing
|
|
||||||
// the location-scoped server configuration graph.
|
|
||||||
const errors: ParseError[] = []
|
|
||||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
|
||||||
if (errors.length || typeof input !== "object" || input === null || !("autoupdate" in input)) return
|
|
||||||
const value = input.autoupdate
|
|
||||||
if (typeof value === "boolean" || value === "notify") return value
|
|
||||||
}
|
|
||||||
|
|
||||||
export function action(current: string, latest: string, policy: Policy): Action {
|
|
||||||
if (policy === false) return "none"
|
|
||||||
if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
|
|
||||||
// Major upgrades are never installed automatically.
|
|
||||||
if (semver.major(latest) !== semver.major(current)) return "none"
|
|
||||||
return "upgrade"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const fs = yield* FileSystem.FileSystem
|
|
||||||
const global = yield* Global.Service
|
|
||||||
const appProcess = yield* AppProcess.Service
|
|
||||||
const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
||||||
|
|
||||||
const readPolicy = Effect.fnUntraced(function* () {
|
|
||||||
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
|
||||||
fs
|
|
||||||
.readFileString(path.join(global.config, name))
|
|
||||||
.pipe(Effect.map(decodePolicy), Effect.catch(() => Effect.succeed(undefined))),
|
|
||||||
)
|
|
||||||
return values.findLast((value) => value !== undefined) ?? true
|
|
||||||
})
|
|
||||||
|
|
||||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
|
||||||
return yield* appProcess
|
|
||||||
.run(ChildProcess.make(command[0], command.slice(1)), {
|
|
||||||
timeout,
|
|
||||||
maxOutputBytes: 100_000,
|
|
||||||
maxErrorBytes: 100_000,
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
Effect.map((result) => ({
|
|
||||||
code: result.exitCode,
|
|
||||||
stdout: result.stdout.toString("utf8"),
|
|
||||||
stderr: result.stderr.toString("utf8"),
|
|
||||||
})),
|
|
||||||
Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const method = Effect.fnUntraced(function* () {
|
|
||||||
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
|
|
||||||
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
|
|
||||||
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
|
|
||||||
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
|
|
||||||
{ method: "yarn", command: ["yarn", "global", "list"] },
|
|
||||||
]
|
|
||||||
const results = yield* Effect.forEach(
|
|
||||||
checks,
|
|
||||||
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
)
|
|
||||||
return results.find((result) => result.result.stdout.includes(packageName))?.check.method
|
|
||||||
})
|
|
||||||
|
|
||||||
const latest = Effect.fnUntraced(function* () {
|
|
||||||
const response = yield* Effect.tryPromise({
|
|
||||||
try: () =>
|
|
||||||
fetch(
|
|
||||||
`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`,
|
|
||||||
{ headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) },
|
|
||||||
),
|
|
||||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
|
||||||
})
|
|
||||||
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
|
|
||||||
const data = yield* Effect.tryPromise({
|
|
||||||
try: () => response.json(),
|
|
||||||
catch: (cause) => new Error("Failed to read update information", { cause }),
|
|
||||||
})
|
|
||||||
if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
|
|
||||||
return yield* Effect.fail(new Error("Update information did not include a version"))
|
|
||||||
}
|
|
||||||
return data.version
|
|
||||||
})
|
|
||||||
|
|
||||||
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
|
|
||||||
const target = `${packageName}@${version}`
|
|
||||||
const commands: Record<Method, string[]> = {
|
|
||||||
npm: ["npm", "install", "--global", target],
|
|
||||||
pnpm: ["pnpm", "install", "--global", target],
|
|
||||||
bun: ["bun", "install", "--global", target],
|
|
||||||
yarn: ["yarn", "global", "add", target],
|
|
||||||
}
|
|
||||||
const result = yield* run(commands[method], "5 minutes")
|
|
||||||
if (result.code === 0) return
|
|
||||||
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
const check = Effect.fn("cli.updater.check")(function* () {
|
|
||||||
if (InstallationLocal || Flag.OPENCODE_DISABLE_AUTOUPDATE)
|
|
||||||
return yield* Effect.logInfo("update check skipped", {
|
|
||||||
reason: InstallationLocal ? "local-install" : "disabled",
|
|
||||||
version: InstallationVersion,
|
|
||||||
channel: InstallationChannel,
|
|
||||||
})
|
|
||||||
const policy = yield* readPolicy()
|
|
||||||
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
|
|
||||||
|
|
||||||
return yield* Effect.gen(function* () {
|
|
||||||
const version = yield* latest()
|
|
||||||
yield* Effect.logInfo("update check", {
|
|
||||||
current: InstallationVersion,
|
|
||||||
latest: version,
|
|
||||||
})
|
|
||||||
const next = action(InstallationVersion, version, policy)
|
|
||||||
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
|
||||||
const detected = yield* method()
|
|
||||||
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
|
|
||||||
yield* upgrade(detected, version)
|
|
||||||
yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected })
|
|
||||||
})
|
|
||||||
}, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
|
|
||||||
|
|
||||||
return Service.of({ check })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer), Layer.provide(Global.defaultLayer))
|
|
||||||
|
|
||||||
export * as Updater from "./updater"
|
|
||||||
+28
-39
@@ -2,46 +2,35 @@ import { run } from "@opencode-ai/tui"
|
|||||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
|
||||||
import { OpenCode } from "@opencode-ai/client"
|
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
|
|
||||||
type Transport = { url: string; headers: RequestInit["headers"] }
|
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
|
||||||
|
|
||||||
export function runTui(transport: Transport, reload?: () => Promise<Transport>) {
|
|
||||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||||
let disposeSlots: (() => void) | undefined
|
return run({
|
||||||
return Effect.gen(function* () {
|
...transport,
|
||||||
const options = { baseUrl: transport.url, headers: transport.headers }
|
args: {},
|
||||||
const api = OpenCode.make(options)
|
config,
|
||||||
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
fetch: gracefulFetch,
|
||||||
Effect.map((response) => response.location.directory),
|
pluginHost: {
|
||||||
Effect.catch(() =>
|
async start() {},
|
||||||
Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)),
|
async dispose() {},
|
||||||
),
|
},
|
||||||
)
|
|
||||||
return yield* run({
|
|
||||||
client: createOpencodeClient({ ...options, directory }),
|
|
||||||
api,
|
|
||||||
reload: reload
|
|
||||||
? async () => {
|
|
||||||
const next = await reload()
|
|
||||||
return {
|
|
||||||
client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }),
|
|
||||||
api: OpenCode.make({ baseUrl: next.url, headers: next.headers }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
args: {},
|
|
||||||
config,
|
|
||||||
pluginHost: {
|
|
||||||
async start(input) {
|
|
||||||
disposeSlots = await loadBuiltinPlugins(input.api, input.runtime)
|
|
||||||
},
|
|
||||||
async dispose() {
|
|
||||||
disposeSlots?.()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}).pipe(Effect.provide(Global.defaultLayer))
|
}).pipe(Effect.provide(Global.defaultLayer))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const legacyDefaults: Record<string, unknown> = {
|
||||||
|
"/config/providers": { providers: [], default: {} },
|
||||||
|
"/provider": { all: [], default: {}, connected: [] },
|
||||||
|
"/agent": [],
|
||||||
|
"/config": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const gracefulFetch = Object.assign(
|
||||||
|
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const response = await fetch(input, init)
|
||||||
|
if (response.status !== 404) return response
|
||||||
|
const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
|
||||||
|
if (fallback === undefined) return response
|
||||||
|
return Response.json(fallback)
|
||||||
|
},
|
||||||
|
{ preconnect: fetch.preconnect },
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { expect, test } from "bun:test"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import fs from "node:fs/promises"
|
|
||||||
import os from "node:os"
|
|
||||||
import path from "node:path"
|
|
||||||
import { Daemon } from "../src/services/daemon"
|
|
||||||
|
|
||||||
test("local channel stores service config with the local service filename", async () => {
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-daemon-"))
|
|
||||||
try {
|
|
||||||
await Effect.runPromise(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const daemon = yield* Daemon.Service
|
|
||||||
yield* daemon.set("autostart", "false")
|
|
||||||
}).pipe(
|
|
||||||
Effect.provide(Daemon.layer),
|
|
||||||
Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
|
|
||||||
Effect.provide(NodeFileSystem.layer),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({
|
|
||||||
autostart: false,
|
|
||||||
})
|
|
||||||
expect(await Bun.file(path.join(root, "config", "service.json")).exists()).toBe(false)
|
|
||||||
} finally {
|
|
||||||
await fs.rm(root, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Effect } from "effect"
|
|
||||||
import path from "node:path"
|
|
||||||
import { Standalone } from "../../src/services/standalone"
|
|
||||||
|
|
||||||
process.argv[1] = path.join(import.meta.dir, "../../src/index.ts")
|
|
||||||
|
|
||||||
await Effect.runPromise(
|
|
||||||
Effect.scoped(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const transport = yield* Standalone.transport()
|
|
||||||
console.log(`${transport.pid} ${transport.url}`)
|
|
||||||
return yield* Effect.never
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { expect, test } from "bun:test"
|
|
||||||
import path from "node:path"
|
|
||||||
|
|
||||||
test("standalone server exits when its owner is killed", async () => {
|
|
||||||
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture/standalone-owner.ts")], {
|
|
||||||
cwd: path.join(import.meta.dir, ".."),
|
|
||||||
env: process.env,
|
|
||||||
stdin: "ignore",
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
})
|
|
||||||
const line = await Promise.race([readLine(owner.stdout), Bun.sleep(10_000).then(() => undefined)])
|
|
||||||
const [rawPID, url] = line?.split(" ") ?? []
|
|
||||||
const pid = Number(rawPID)
|
|
||||||
|
|
||||||
try {
|
|
||||||
expect(pid).toBeGreaterThan(0)
|
|
||||||
expect(url).toStartWith("http://127.0.0.1:")
|
|
||||||
expect(running(pid)).toBe(true)
|
|
||||||
|
|
||||||
owner.kill("SIGKILL")
|
|
||||||
await owner.exited
|
|
||||||
|
|
||||||
expect(await waitForExit(pid)).toBe(true)
|
|
||||||
} finally {
|
|
||||||
owner.kill("SIGKILL")
|
|
||||||
if (running(pid)) process.kill(pid, "SIGKILL")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function readLine(stream: ReadableStream<Uint8Array>) {
|
|
||||||
const reader = stream.getReader()
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
const chunks: string[] = []
|
|
||||||
while (true) {
|
|
||||||
const result = await reader.read()
|
|
||||||
if (result.done) break
|
|
||||||
chunks.push(decoder.decode(result.value, { stream: true }))
|
|
||||||
const output = chunks.join("")
|
|
||||||
const newline = output.indexOf("\n")
|
|
||||||
if (newline !== -1) {
|
|
||||||
reader.releaseLock()
|
|
||||||
return output.slice(0, newline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.releaseLock()
|
|
||||||
return chunks.join("") + decoder.decode()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForExit(pid: number, attempts = 100): Promise<boolean> {
|
|
||||||
if (!running(pid)) return true
|
|
||||||
if (attempts === 0) return false
|
|
||||||
await Bun.sleep(50)
|
|
||||||
return waitForExit(pid, attempts - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function running(pid: number) {
|
|
||||||
if (!Number.isSafeInteger(pid) || pid <= 0) return false
|
|
||||||
try {
|
|
||||||
process.kill(pid, 0)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,16 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { compile, emitEffectImported, emitEffectShape, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
||||||
import {
|
import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract"
|
||||||
ClientApi,
|
|
||||||
effectOmitEndpoints,
|
|
||||||
endpointNames,
|
|
||||||
groupNames,
|
|
||||||
promiseOmitEndpoints,
|
|
||||||
} from "@opencode-ai/protocol/client"
|
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
const promiseContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints })
|
const contract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints })
|
||||||
const effectContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: effectOmitEndpoints })
|
|
||||||
|
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.all(
|
Effect.all(
|
||||||
[
|
[
|
||||||
write(
|
write(
|
||||||
emitPromise(promiseContract, {
|
emitPromise(contract, {
|
||||||
outputTypes: {
|
outputTypes: {
|
||||||
"events.subscribe": {
|
"events.subscribe": {
|
||||||
name: "OpenCodeEventEncoded",
|
name: "OpenCodeEventEncoded",
|
||||||
@@ -28,14 +21,10 @@ await Effect.runPromise(
|
|||||||
fileURLToPath(new URL("../src/generated", import.meta.url)),
|
fileURLToPath(new URL("../src/generated", import.meta.url)),
|
||||||
),
|
),
|
||||||
write(
|
write(
|
||||||
emitEffectImported(effectContract, { module: "../contract", api: "ClientApi" }),
|
emitEffectImported(contract, { module: "../contract", api: "ClientApi" }),
|
||||||
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
||||||
),
|
),
|
||||||
write(
|
|
||||||
emitEffectShape(effectContract, { module: "@opencode-ai/protocol/client", api: "ClientApi" }),
|
|
||||||
fileURLToPath(new URL("../../plugin/src/v2/effect/generated", import.meta.url)),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
{ concurrency: 3, discard: true },
|
{ concurrency: 2, discard: true },
|
||||||
).pipe(Effect.provide(NodeFileSystem.layer)),
|
).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,53 @@
|
|||||||
export {
|
import { makeDefaultApi } from "@opencode-ai/protocol/api"
|
||||||
ClientApi,
|
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||||
effectOmitEndpoints,
|
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||||
endpointNames,
|
|
||||||
groupNames,
|
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
|
||||||
promiseOmitEndpoints,
|
"@opencode-ai/client/LocationMiddleware",
|
||||||
} from "@opencode-ai/protocol/client"
|
) {}
|
||||||
|
|
||||||
|
class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocationMiddleware>()(
|
||||||
|
"@opencode-ai/client/SessionLocationMiddleware",
|
||||||
|
{ error: [InvalidRequestError, SessionNotFoundError] },
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export const ClientApi = makeDefaultApi({
|
||||||
|
locationMiddleware: LocationMiddleware,
|
||||||
|
sessionLocationMiddleware: SessionLocationMiddleware,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const groupNames = {
|
||||||
|
"server.health": "health",
|
||||||
|
"server.location": "location",
|
||||||
|
"server.agent": "agents",
|
||||||
|
"server.session": "sessions",
|
||||||
|
"server.message": "messages",
|
||||||
|
"server.model": "models",
|
||||||
|
"server.provider": "providers",
|
||||||
|
"server.integration": "integrations",
|
||||||
|
"server.credential": "credentials",
|
||||||
|
"server.permission": "permissions",
|
||||||
|
"server.fs": "files",
|
||||||
|
"server.command": "commands",
|
||||||
|
"server.skill": "skills",
|
||||||
|
"server.event": "events",
|
||||||
|
"server.pty": "ptys",
|
||||||
|
"server.question": "questions",
|
||||||
|
"server.reference": "references",
|
||||||
|
"server.projectCopy": "projectCopies",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const endpointNames = {
|
||||||
|
"session.messages": "list",
|
||||||
|
"integration.connect.key": "connectKey",
|
||||||
|
"integration.connect.oauth": "connectOauth",
|
||||||
|
"integration.attempt.status": "attemptStatus",
|
||||||
|
"integration.attempt.complete": "attemptComplete",
|
||||||
|
"integration.attempt.cancel": "attemptCancel",
|
||||||
|
"permission.request.list": "listRequests",
|
||||||
|
"permission.saved.list": "listSaved",
|
||||||
|
"permission.saved.remove": "removeSaved",
|
||||||
|
"question.request.list": "listRequests",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const omitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"])
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+121
-1861
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,2 @@
|
|||||||
export * from "./generated/index"
|
export * from "./generated/index"
|
||||||
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||||
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Schema } from "effect"
|
|||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { Location as CoreLocation } from "@opencode-ai/core/location"
|
import { Location as CoreLocation } from "@opencode-ai/core/location"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input"
|
import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input"
|
||||||
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
|
||||||
@@ -27,14 +26,10 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
|
|||||||
expect(CoreLocation.Ref).toBe(Location.Ref)
|
expect(CoreLocation.Ref).toBe(Location.Ref)
|
||||||
expect(ModelV2.Ref).toBe(Model.Ref)
|
expect(ModelV2.Ref).toBe(Model.Ref)
|
||||||
expect(SessionV2.Info).toBe(Session.Info)
|
expect(SessionV2.Info).toBe(Session.Info)
|
||||||
expect(ProjectV2.Current).toBe(Project.Current)
|
|
||||||
expect(ProjectV2.Directory).toBe(Project.Directory)
|
|
||||||
expect(ProjectV2.Directories).toBe(Project.Directories)
|
|
||||||
expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted)
|
expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted)
|
||||||
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
|
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
|
||||||
expect(CorePrompt).toBe(Prompt)
|
expect(CorePrompt).toBe(Prompt)
|
||||||
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
||||||
expect(Api.groups["server.project"].identifier).toBe("server.project")
|
|
||||||
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
|
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
|
||||||
expect(Session.ID.create()).toStartWith("ses_")
|
expect(Session.ID.create()).toStartWith("ses_")
|
||||||
expect(Project.ID.global).toBe("global")
|
expect(Project.ID.global).toBe("global")
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ import { DateTime, Effect, Stream } from "effect"
|
|||||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect"
|
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect"
|
||||||
|
|
||||||
test("session.get returns the decoded Effect projection", async () => {
|
test("sessions.get returns the decoded Effect projection", async () => {
|
||||||
const httpClient = HttpClient.make((request) =>
|
const httpClient = HttpClient.make((request) =>
|
||||||
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
|
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
|
||||||
)
|
)
|
||||||
const result = await Effect.gen(function* () {
|
const result = await Effect.gen(function* () {
|
||||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
return yield* client.session.get({ sessionID: Session.ID.make("ses_test") })
|
return yield* client.sessions.get({ sessionID: Session.ID.make("ses_test") })
|
||||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("event.subscribe exposes and decodes the native Effect event stream", async () => {
|
test("events.subscribe exposes and decodes the native Effect event stream", async () => {
|
||||||
const httpClient = HttpClient.make((request) =>
|
const httpClient = HttpClient.make((request) =>
|
||||||
Effect.succeed(
|
Effect.succeed(
|
||||||
HttpClientResponse.fromWeb(
|
HttpClientResponse.fromWeb(
|
||||||
@@ -30,7 +30,7 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
|
|||||||
)
|
)
|
||||||
const events = await Effect.gen(function* () {
|
const events = await Effect.gen(function* () {
|
||||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
return yield* client.event.subscribe().pipe(Stream.runCollect)
|
return yield* client.events.subscribe().pipe(Stream.runCollect)
|
||||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"])
|
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"])
|
||||||
@@ -40,7 +40,7 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
|
|||||||
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
|
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("event.subscribe terminates on Effect protocol decode failures", async () => {
|
test("events.subscribe terminates on Effect protocol decode failures", async () => {
|
||||||
const httpClient = HttpClient.make((request) =>
|
const httpClient = HttpClient.make((request) =>
|
||||||
Effect.succeed(
|
Effect.succeed(
|
||||||
HttpClientResponse.fromWeb(
|
HttpClientResponse.fromWeb(
|
||||||
@@ -53,7 +53,7 @@ test("event.subscribe terminates on Effect protocol decode failures", async () =
|
|||||||
)
|
)
|
||||||
const error = await Effect.gen(function* () {
|
const error = await Effect.gen(function* () {
|
||||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
return yield* client.event.subscribe().pipe(Stream.runCollect, Effect.flip)
|
return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip)
|
||||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
expect(error._tag).toBe("ClientError")
|
expect(error._tag).toBe("ClientError")
|
||||||
@@ -112,41 +112,41 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
})
|
})
|
||||||
const result = await Effect.gen(function* () {
|
const result = await Effect.gen(function* () {
|
||||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
const page = yield* client.session.list({ limit: 10 })
|
const page = yield* client.sessions.list({ limit: 10 })
|
||||||
const active = yield* client.session.active()
|
const active = yield* client.sessions.active()
|
||||||
const created = yield* client.session.create({
|
const created = yield* client.sessions.create({
|
||||||
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
|
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
|
||||||
})
|
})
|
||||||
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
|
yield* client.sessions.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
|
||||||
yield* client.session.switchModel({
|
yield* client.sessions.switchModel({
|
||||||
sessionID: Session.ID.make("ses_test"),
|
sessionID: Session.ID.make("ses_test"),
|
||||||
model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
|
model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
|
||||||
})
|
})
|
||||||
const admitted = yield* client.session.prompt({
|
const admitted = yield* client.sessions.prompt({
|
||||||
sessionID: Session.ID.make("ses_test"),
|
sessionID: Session.ID.make("ses_test"),
|
||||||
prompt: Prompt.make({ text: "Hello" }),
|
prompt: Prompt.make({ text: "Hello" }),
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
yield* client.session.compact({ sessionID: Session.ID.make("ses_test") })
|
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
|
||||||
yield* client.session.wait({ sessionID: Session.ID.make("ses_test") })
|
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
|
||||||
const context = yield* client.session.context({ sessionID: Session.ID.make("ses_test") })
|
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
|
||||||
const history = yield* client.session.history({
|
const history = yield* client.sessions.history({
|
||||||
sessionID: Session.ID.make("ses_test"),
|
sessionID: Session.ID.make("ses_test"),
|
||||||
after: 0,
|
after: 0,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
})
|
})
|
||||||
const historyNext = history.hasMore
|
const historyNext = history.hasMore
|
||||||
? yield* client.session.history({
|
? yield* client.sessions.history({
|
||||||
sessionID: Session.ID.make("ses_test"),
|
sessionID: Session.ID.make("ses_test"),
|
||||||
after: history.data.at(-1)?.durable?.seq,
|
after: history.data.at(-1)?.durable?.seq,
|
||||||
limit: 2,
|
limit: 2,
|
||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
const events = yield* client.session
|
const events = yield* client.sessions
|
||||||
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
||||||
.pipe(Stream.runCollect)
|
.pipe(Stream.runCollect)
|
||||||
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
|
yield* client.sessions.interrupt({ sessionID: Session.ID.make("ses_test") })
|
||||||
const message = yield* client.session.message({
|
const message = yield* client.sessions.message({
|
||||||
sessionID: Session.ID.make("ses_test"),
|
sessionID: Session.ID.make("ses_test"),
|
||||||
messageID: SessionMessage.ID.make("msg_model"),
|
messageID: SessionMessage.ID.make("msg_model"),
|
||||||
})
|
})
|
||||||
@@ -171,7 +171,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session.history retains the typed SessionNotFoundError", async () => {
|
test("sessions.history retains the typed SessionNotFoundError", async () => {
|
||||||
const httpClient = HttpClient.make((request) =>
|
const httpClient = HttpClient.make((request) =>
|
||||||
Effect.succeed(
|
Effect.succeed(
|
||||||
HttpClientResponse.fromWeb(
|
HttpClientResponse.fromWeb(
|
||||||
@@ -185,7 +185,7 @@ test("session.history retains the typed SessionNotFoundError", async () => {
|
|||||||
)
|
)
|
||||||
const error = await Effect.gen(function* () {
|
const error = await Effect.gen(function* () {
|
||||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
return yield* client.session
|
return yield* client.sessions
|
||||||
.history({
|
.history({
|
||||||
sessionID: Session.ID.make("ses_missing"),
|
sessionID: Session.ID.make("ses_missing"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,28 +7,25 @@ test("exposes every standard HTTP API group", () => {
|
|||||||
expect(Object.keys(client)).toEqual([
|
expect(Object.keys(client)).toEqual([
|
||||||
"health",
|
"health",
|
||||||
"location",
|
"location",
|
||||||
"agent",
|
"agents",
|
||||||
"session",
|
"sessions",
|
||||||
"message",
|
"messages",
|
||||||
"model",
|
"models",
|
||||||
"generate",
|
"providers",
|
||||||
"provider",
|
"integrations",
|
||||||
"integration",
|
"credentials",
|
||||||
"credential",
|
"permissions",
|
||||||
"project",
|
"files",
|
||||||
"permission",
|
"commands",
|
||||||
"file",
|
"skills",
|
||||||
"command",
|
"events",
|
||||||
"skill",
|
"ptys",
|
||||||
"event",
|
"questions",
|
||||||
"pty",
|
"references",
|
||||||
"shell",
|
"projectCopies",
|
||||||
"question",
|
|
||||||
"reference",
|
|
||||||
"projectCopy",
|
|
||||||
])
|
])
|
||||||
expect(Object.keys(client.message)).toEqual(["list"])
|
expect(Object.keys(client.messages)).toEqual(["list"])
|
||||||
expect(Object.keys(client.integration)).toEqual([
|
expect(Object.keys(client.integrations)).toEqual([
|
||||||
"list",
|
"list",
|
||||||
"get",
|
"get",
|
||||||
"connectKey",
|
"connectKey",
|
||||||
@@ -37,95 +34,11 @@ test("exposes every standard HTTP API group", () => {
|
|||||||
"attemptComplete",
|
"attemptComplete",
|
||||||
"attemptCancel",
|
"attemptCancel",
|
||||||
])
|
])
|
||||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
expect(Object.keys(client.files)).toEqual(["list", "find"])
|
||||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
expect(Object.keys(client.ptys)).toEqual(["list", "create", "get", "update", "remove"])
|
||||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"])
|
|
||||||
expect(Object.keys(client.project)).toEqual(["current", "directories"])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("file.read returns binary content from the public HTTP contract", async () => {
|
test("sessions.get returns the wire projection", async () => {
|
||||||
let request: Request | undefined
|
|
||||||
const client = OpenCode.make({
|
|
||||||
baseUrl: "http://localhost:3000",
|
|
||||||
fetch: async (input) => {
|
|
||||||
request = input instanceof Request ? input : new Request(input)
|
|
||||||
return new Response(new Uint8Array([104, 105]))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const content = await client.file.read({
|
|
||||||
path: "src/a b#c.ts",
|
|
||||||
location: { directory: "/tmp/project" },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(Array.from(content)).toEqual([104, 105])
|
|
||||||
expect(request?.url).toBe(
|
|
||||||
"http://localhost:3000/api/fs/read/src/a%20b%23c.ts?location%5Bdirectory%5D=%2Ftmp%2Fproject",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("project methods use the public HTTP contract", async () => {
|
|
||||||
const requests: string[] = []
|
|
||||||
const client = OpenCode.make({
|
|
||||||
baseUrl: "http://localhost:3000",
|
|
||||||
fetch: async (input) => {
|
|
||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
|
||||||
requests.push(url)
|
|
||||||
if (url.includes("/directories")) return Response.json([])
|
|
||||||
return Response.json({ id: "proj_test", directory: "/tmp/project" })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const current = await client.project.current({ location: { workspace: "wrk_test" } })
|
|
||||||
const directories = await client.project.directories({
|
|
||||||
projectID: current.id,
|
|
||||||
location: { directory: current.directory },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(current).toEqual({ id: "proj_test", directory: "/tmp/project" })
|
|
||||||
expect(directories).toEqual([])
|
|
||||||
expect(requests).toEqual([
|
|
||||||
"http://localhost:3000/api/project/current?location%5Bworkspace%5D=wrk_test",
|
|
||||||
"http://localhost:3000/api/project/proj_test/directories?location%5Bdirectory%5D=%2Ftmp%2Fproject",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("shell list and remove use the public HTTP contract", async () => {
|
|
||||||
const requests: Array<{ method: string; url: string }> = []
|
|
||||||
const shell = {
|
|
||||||
id: "sh_test",
|
|
||||||
status: "running",
|
|
||||||
command: "pwd",
|
|
||||||
cwd: "/tmp/project",
|
|
||||||
shell: "/bin/zsh",
|
|
||||||
file: "/tmp/opencode-shell",
|
|
||||||
metadata: { sessionID: "ses_test" },
|
|
||||||
time: { started: 1_717_171_717_000 },
|
|
||||||
}
|
|
||||||
const client = OpenCode.make({
|
|
||||||
baseUrl: "http://localhost:3000",
|
|
||||||
fetch: async (input, init) => {
|
|
||||||
const request = input instanceof Request ? input : new Request(input, init)
|
|
||||||
requests.push({ method: request.method, url: request.url })
|
|
||||||
if (request.method === "DELETE") return new Response(null, { status: 204 })
|
|
||||||
return Response.json({
|
|
||||||
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
|
|
||||||
data: [shell],
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await client.shell.list({ location: { directory: "/tmp/project" } })
|
|
||||||
await client.shell.remove({ id: shell.id })
|
|
||||||
|
|
||||||
expect(result.data).toEqual([shell])
|
|
||||||
expect(requests).toEqual([
|
|
||||||
{ method: "GET", url: "http://localhost:3000/api/shell?location%5Bdirectory%5D=%2Ftmp%2Fproject" },
|
|
||||||
{ method: "DELETE", url: "http://localhost:3000/api/shell/sh_test" },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("session.get returns the wire projection", async () => {
|
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
baseUrl: "http://localhost:3000",
|
baseUrl: "http://localhost:3000",
|
||||||
fetch: async (input) => {
|
fetch: async (input) => {
|
||||||
@@ -136,12 +49,12 @@ test("session.get returns the wire projection", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await client.session.get({ sessionID: "ses_test" })
|
const result = await client.sessions.get({ sessionID: "ses_test" })
|
||||||
|
|
||||||
expect(result.time.created).toBe(1_717_171_717_000)
|
expect(result.time.created).toBe(1_717_171_717_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("event.subscribe exposes the Promise event stream wire projection", async () => {
|
test("events.subscribe exposes the Promise event stream wire projection", async () => {
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
baseUrl: "http://localhost:3000",
|
baseUrl: "http://localhost:3000",
|
||||||
fetch: async () =>
|
fetch: async () =>
|
||||||
@@ -152,19 +65,19 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
const events = []
|
const events = []
|
||||||
for await (const event of client.event.subscribe()) events.push(event)
|
for await (const event of client.events.subscribe()) events.push(event)
|
||||||
|
|
||||||
expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent])
|
expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent])
|
||||||
expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000)
|
expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("event.subscribe terminates on malformed Promise SSE data", async () => {
|
test("events.subscribe terminates on malformed Promise SSE data", async () => {
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
baseUrl: "http://localhost:3000",
|
baseUrl: "http://localhost:3000",
|
||||||
fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }),
|
fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }),
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
|
await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
|
||||||
name: "ClientError",
|
name: "ClientError",
|
||||||
reason: "MalformedResponse",
|
reason: "MalformedResponse",
|
||||||
})
|
})
|
||||||
@@ -199,31 +112,31 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const page = await client.session.list({ limit: 10, order: "desc" })
|
const page = await client.sessions.list({ limit: 10, order: "desc" })
|
||||||
const active = await client.session.active()
|
const active = await client.sessions.active()
|
||||||
const created = await client.session.create({ location: { directory: "/tmp/project" } })
|
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
|
||||||
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
|
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||||
await client.session.switchModel({
|
await client.sessions.switchModel({
|
||||||
sessionID: "ses_test",
|
sessionID: "ses_test",
|
||||||
model: { id: "claude", providerID: "anthropic" },
|
model: { id: "claude", providerID: "anthropic" },
|
||||||
})
|
})
|
||||||
const admitted = await client.session.prompt({
|
const admitted = await client.sessions.prompt({
|
||||||
sessionID: "ses_test",
|
sessionID: "ses_test",
|
||||||
prompt: { text: "Hello" },
|
prompt: { text: "Hello" },
|
||||||
resume: false,
|
resume: false,
|
||||||
})
|
})
|
||||||
await client.session.compact({ sessionID: "ses_test" })
|
await client.sessions.compact({ sessionID: "ses_test" })
|
||||||
await client.session.wait({ sessionID: "ses_test" })
|
await client.sessions.wait({ sessionID: "ses_test" })
|
||||||
const context = await client.session.context({ sessionID: "ses_test" })
|
const context = await client.sessions.context({ sessionID: "ses_test" })
|
||||||
const history = await client.session.history({ sessionID: "ses_test", after: 0, limit: 1 })
|
const history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 })
|
||||||
const historyAfter = history.data.at(-1)?.durable?.seq
|
const historyAfter = history.data.at(-1)?.durable?.seq
|
||||||
const historyNext = history.hasMore
|
const historyNext = history.hasMore
|
||||||
? await client.session.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
|
? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
|
||||||
: undefined
|
: undefined
|
||||||
const events = []
|
const events = []
|
||||||
for await (const event of client.session.events({ sessionID: "ses_test", after: 0 })) events.push(event)
|
for await (const event of client.sessions.events({ sessionID: "ses_test", after: 0 })) events.push(event)
|
||||||
await client.session.interrupt({ sessionID: "ses_test" })
|
await client.sessions.interrupt({ sessionID: "ses_test" })
|
||||||
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
|
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||||
|
|
||||||
expect(page.cursor.next).toBe("next")
|
expect(page.cursor.next).toBe("next")
|
||||||
expect(active).toEqual({ ses_test: { type: "running" } })
|
expect(active).toEqual({ ses_test: { type: "running" } })
|
||||||
@@ -266,14 +179,14 @@ test("middleware errors remain declared client errors", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.session.create({})
|
await client.sessions.create({})
|
||||||
throw new Error("Expected request to fail")
|
throw new Error("Expected request to fail")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(isUnauthorizedError(error)).toBe(true)
|
expect(isUnauthorizedError(error)).toBe(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session.history decodes SessionNotFoundError", async () => {
|
test("sessions.history decodes SessionNotFoundError", async () => {
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
baseUrl: "http://localhost:3000",
|
baseUrl: "http://localhost:3000",
|
||||||
fetch: async () =>
|
fetch: async () =>
|
||||||
@@ -284,7 +197,7 @@ test("session.history decodes SessionNotFoundError", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.session.history({ sessionID: "ses_missing" })
|
await client.sessions.history({ sessionID: "ses_missing" })
|
||||||
throw new Error("Expected request to fail")
|
throw new Error("Expected request to fail")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(isSessionNotFoundError(error)).toBe(true)
|
expect(isSessionNotFoundError(error)).toBe(true)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from "zod"
|
|||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
|
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
|
||||||
|
|
||||||
const DISCORD_ALERT_ROLE_ID = "1511795723262365887"
|
const DISCORD_ALERT_ROLE_ID = "1520924666359713863"
|
||||||
|
|
||||||
const basePayload = z.object({
|
const basePayload = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export async function handler(
|
|||||||
const providerBudgetTracker = createProviderBudgetTracker(
|
const providerBudgetTracker = createProviderBudgetTracker(
|
||||||
modelInfo.providers.map((provider) => ({ ...zenData.providers[provider.id], ...provider })),
|
modelInfo.providers.map((provider) => ({ ...zenData.providers[provider.id], ...provider })),
|
||||||
)
|
)
|
||||||
const providerBudgetUsage = await providerBudgetTracker?.check()
|
const providerBudget = await providerBudgetTracker?.check()
|
||||||
|
|
||||||
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
|
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
|
||||||
const providerInfo = selectProvider(
|
const providerInfo = selectProvider(
|
||||||
@@ -151,7 +151,7 @@ export async function handler(
|
|||||||
stickyProvider,
|
stickyProvider,
|
||||||
modelTpmLimits,
|
modelTpmLimits,
|
||||||
modelTpsLimits,
|
modelTpsLimits,
|
||||||
providerBudgetUsage,
|
providerBudget,
|
||||||
)
|
)
|
||||||
validateModelSettings(billingSource, authInfo)
|
validateModelSettings(billingSource, authInfo)
|
||||||
updateProviderKey(authInfo, providerInfo)
|
updateProviderKey(authInfo, providerInfo)
|
||||||
@@ -201,7 +201,10 @@ export async function handler(
|
|||||||
if (v === "$model") return headers.set(k, model)
|
if (v === "$model") return headers.set(k, model)
|
||||||
if (v === "$request") return headers.set(k, requestId)
|
if (v === "$request") return headers.set(k, requestId)
|
||||||
if (v === "$project") return headers.set(k, projectId)
|
if (v === "$project") return headers.set(k, projectId)
|
||||||
if (v === "$workspace" && authInfo?.workspaceID) return headers.set(k, authInfo.workspaceID)
|
if (v === "$workspace") {
|
||||||
|
if (authInfo?.workspaceID) headers.set(k, authInfo.workspaceID)
|
||||||
|
return
|
||||||
|
}
|
||||||
headers.set(k, v)
|
headers.set(k, v)
|
||||||
})
|
})
|
||||||
headers.delete("host")
|
headers.delete("host")
|
||||||
@@ -213,6 +216,9 @@ export async function handler(
|
|||||||
return headers
|
return headers
|
||||||
})(),
|
})(),
|
||||||
body: reqBody,
|
body: reqBody,
|
||||||
|
// Propagate caller disconnects to the upstream provider request so
|
||||||
|
// abandoned Console requests do not leave orphaned inference work open.
|
||||||
|
signal: input.request.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (providerInfo.id.startsWith("console.")) {
|
if (providerInfo.id.startsWith("console.")) {
|
||||||
@@ -281,7 +287,7 @@ export async function handler(
|
|||||||
const costInfo = calculateCost(modelInfo, usageInfo)
|
const costInfo = calculateCost(modelInfo, usageInfo)
|
||||||
await trialLimiter?.track(usageInfo)
|
await trialLimiter?.track(usageInfo)
|
||||||
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
|
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
|
||||||
await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent)
|
await providerBudgetTracker?.track(providerInfo.id, providerInfo.budgetPriority, costInfo.totalCostInCent)
|
||||||
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||||
await reload(billingSource, authInfo, costInfo)
|
await reload(billingSource, authInfo, costInfo)
|
||||||
json.cost = calculateOccurredCost(billingSource, costInfo)
|
json.cost = calculateOccurredCost(billingSource, costInfo)
|
||||||
@@ -308,9 +314,10 @@ export async function handler(
|
|||||||
const streamConverter = createStreamPartConverter(providerInfo.format, opts.format)
|
const streamConverter = createStreamPartConverter(providerInfo.format, opts.format)
|
||||||
const usageParser = providerInfo.createUsageParser()
|
const usageParser = providerInfo.createUsageParser()
|
||||||
const binaryDecoder = providerInfo.createBinaryStreamDecoder()
|
const binaryDecoder = providerInfo.createBinaryStreamDecoder()
|
||||||
|
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(c) {
|
start(c) {
|
||||||
const reader = res.body?.getReader()
|
reader = res.body?.getReader()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
|
|
||||||
@@ -342,7 +349,11 @@ export async function handler(
|
|||||||
timestampLastByte,
|
timestampLastByte,
|
||||||
usageInfo,
|
usageInfo,
|
||||||
)
|
)
|
||||||
await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent)
|
await providerBudgetTracker?.track(
|
||||||
|
providerInfo.id,
|
||||||
|
providerInfo.budgetPriority,
|
||||||
|
costInfo.totalCostInCent,
|
||||||
|
)
|
||||||
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||||
await reload(billingSource, authInfo, costInfo)
|
await reload(billingSource, authInfo, costInfo)
|
||||||
const cost = calculateOccurredCost(billingSource, costInfo)
|
const cost = calculateOccurredCost(billingSource, costInfo)
|
||||||
@@ -392,6 +403,11 @@ export async function handler(
|
|||||||
|
|
||||||
return pump()
|
return pump()
|
||||||
},
|
},
|
||||||
|
cancel() {
|
||||||
|
// When the downstream caller stops reading, release the upstream
|
||||||
|
// response body instead of keeping the provider/inference stream alive.
|
||||||
|
return reader?.cancel()
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return new Response(stream, {
|
return new Response(stream, {
|
||||||
status: resStatus,
|
status: resStatus,
|
||||||
@@ -399,6 +415,15 @@ export async function handler(
|
|||||||
headers: resHeaders,
|
headers: resHeaders,
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// The caller disconnected before we finished. Because the outbound provider
|
||||||
|
// request shares input.request.signal, an aborted caller surfaces here as an
|
||||||
|
// AbortError. There is no client left to receive a body, so skip the error
|
||||||
|
// metric and 500 and return a quiet client-closed response.
|
||||||
|
if (input.request.signal.aborted || error?.name === "AbortError") {
|
||||||
|
logger.debug("REQUEST ABORTED BY CALLER")
|
||||||
|
return new Response(null, { status: 499 })
|
||||||
|
}
|
||||||
|
|
||||||
logger.metric({
|
logger.metric({
|
||||||
"error.type": error.constructor.name,
|
"error.type": error.constructor.name,
|
||||||
"error.message": error.message,
|
"error.message": error.message,
|
||||||
@@ -509,7 +534,12 @@ export async function handler(
|
|||||||
stickyProviderId: string | undefined,
|
stickyProviderId: string | undefined,
|
||||||
modelTpmLimits: Record<string, number> | undefined,
|
modelTpmLimits: Record<string, number> | undefined,
|
||||||
modelTpsLimits: Record<string, { qualify: number; unqualify: number }> | undefined,
|
modelTpsLimits: Record<string, { qualify: number; unqualify: number }> | undefined,
|
||||||
providerBudgetUsage: Record<string, number> | undefined,
|
providerBudget:
|
||||||
|
| {
|
||||||
|
qualify: (providerId: string, priority: number) => boolean
|
||||||
|
prefer: (providerId: string, priority: number) => boolean
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
) {
|
) {
|
||||||
const modelProvider = (() => {
|
const modelProvider = (() => {
|
||||||
// Byok is top priority b/c if user set their own API key, we should use it
|
// Byok is top priority b/c if user set their own API key, we should use it
|
||||||
@@ -533,10 +563,9 @@ export async function handler(
|
|||||||
.filter((provider) => provider.weight !== 0)
|
.filter((provider) => provider.weight !== 0)
|
||||||
.filter((provider) => !retry.excludeProviders.includes(provider.id))
|
.filter((provider) => !retry.excludeProviders.includes(provider.id))
|
||||||
.filter((provider) => {
|
.filter((provider) => {
|
||||||
if (provider.budgetMode !== "fill") return true
|
if (provider.budgetPriority === undefined) return true
|
||||||
const budget = zenData.providers[provider.id]?.budget
|
if (!providerBudget) return true
|
||||||
if (budget === undefined) return false
|
return providerBudget.qualify(provider.id, provider.budgetPriority)
|
||||||
return (providerBudgetUsage?.[provider.id] ?? 0) < centsToMicroCents(budget * 100)
|
|
||||||
})
|
})
|
||||||
.filter((provider) => {
|
.filter((provider) => {
|
||||||
if (!provider.tpmLimit) return true
|
if (!provider.tpmLimit) return true
|
||||||
@@ -573,15 +602,19 @@ export async function handler(
|
|||||||
const stickProvider = allProviders.find((provider) => provider.id === stickyProviderId)
|
const stickProvider = allProviders.find((provider) => provider.id === stickyProviderId)
|
||||||
if (!stickProvider) return provider
|
if (!stickProvider) return provider
|
||||||
|
|
||||||
// stick provider exists + selected provider is API type => use sticky provider
|
const preferBudgetProvider =
|
||||||
if (!provider.tpsGoal) return stickProvider
|
provider.budgetPriority !== undefined && providerBudget?.prefer(provider.id, provider.budgetPriority)
|
||||||
|
|
||||||
// stick provier exists + selected provider is GPU type + GPU not idle => use selected provider
|
const preferTpsProvider = (() => {
|
||||||
const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? {
|
if (!provider.tpsGoal) return false
|
||||||
qualify: 0,
|
const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? {
|
||||||
unqualify: 0,
|
qualify: 0,
|
||||||
}
|
unqualify: 0,
|
||||||
if (tps.qualify <= tps.unqualify * 3) return stickProvider
|
}
|
||||||
|
return tps.qualify > tps.unqualify * 3
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (!preferBudgetProvider && !preferTpsProvider) return stickProvider
|
||||||
|
|
||||||
return provider
|
return provider
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function createModelTpsLimiter(providers: { id: string; model: string; tp
|
|||||||
)
|
)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const currInterval = toInterval(new Date(now))
|
const currInterval = toInterval(new Date(now))
|
||||||
const prevInterval = toInterval(new Date(now - 60 * 1000))
|
const prevInterval = toInterval(new Date(now - 60_000))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
check: async () => {
|
check: async () => {
|
||||||
|
|||||||
@@ -2,50 +2,148 @@ import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
|||||||
import { buildRateLimitKey, getRedis } from "./redis"
|
import { buildRateLimitKey, getRedis } from "./redis"
|
||||||
import { logger } from "./logger"
|
import { logger } from "./logger"
|
||||||
|
|
||||||
|
// Per-provider, per-minute budget with priorities. The budget belongs to a
|
||||||
|
// provider and is shared across every model that routes to it. Each model's
|
||||||
|
// provider entry carries a `budgetPriority`: priority 1 ("always") routes
|
||||||
|
// unconditionally, while higher priorities ("fill") only route while the provider's
|
||||||
|
// current-minute spend through that priority is still under budget.
|
||||||
|
//
|
||||||
|
// Spend is tracked per (provider, priority, minute) so a fill priority can yield its
|
||||||
|
// leftover headroom to the next priority down. The previous minute is also read so
|
||||||
|
// higher priorities can reserve the next minute's budget first.
|
||||||
export function createProviderBudgetTracker(
|
export function createProviderBudgetTracker(
|
||||||
providers: {
|
providers: {
|
||||||
id: string
|
id: string
|
||||||
budget?: number
|
budget?: number
|
||||||
budgetContribution?: number
|
budgetContribution?: number
|
||||||
budgetMode?: "always" | "fill"
|
budgetPriority?: number
|
||||||
}[],
|
}[],
|
||||||
) {
|
) {
|
||||||
const tracked = providers.filter(
|
const tracked = providers.filter(
|
||||||
(provider) => provider.budget !== undefined && provider.budgetContribution !== undefined,
|
(provider) =>
|
||||||
|
provider.budget !== undefined &&
|
||||||
|
provider.budgetContribution !== undefined &&
|
||||||
|
provider.budgetPriority !== undefined,
|
||||||
)
|
)
|
||||||
if (tracked.length === 0) return undefined
|
if (tracked.length === 0) return undefined
|
||||||
|
|
||||||
const interval = new Date()
|
const intervalAt = (date: Date) =>
|
||||||
.toISOString()
|
date
|
||||||
.replace(/[^0-9]/g, "")
|
.toISOString()
|
||||||
.substring(0, 12)
|
.replace(/[^0-9]/g, "")
|
||||||
|
.substring(0, 12)
|
||||||
|
const now = new Date()
|
||||||
|
const currInterval = intervalAt(now)
|
||||||
|
const prevInterval = intervalAt(new Date(now.getTime() - 60_000))
|
||||||
|
|
||||||
const redis = getRedis()
|
const redis = getRedis()
|
||||||
const keys = Object.fromEntries(
|
const key = (providerId: string, priority: number, withInterval: string) =>
|
||||||
tracked.map((provider) => [provider.id, buildRateLimitKey("provider-budget", provider.id, interval)]),
|
buildRateLimitKey("provider-budget", `${providerId}:${priority}`, withInterval)
|
||||||
)
|
|
||||||
let budgetUsage: Record<string, number> = {}
|
const budgetByProvider = tracked.reduce<Record<string, number>>((acc, provider) => {
|
||||||
|
acc[provider.id] = provider.budget!
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const maxPriorityByProvider = tracked.reduce<Record<string, number>>((acc, provider) => {
|
||||||
|
acc[provider.id] = Math.max(acc[provider.id] ?? 0, provider.budgetPriority!)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
// Effective budget in micro-cents per provider/priority, computed in check()
|
||||||
|
// from the configured budget minus previous-minute usage from higher priorities.
|
||||||
|
let effectiveBudget: Record<string, Record<number, number>> = {}
|
||||||
|
// Cumulative current-minute spend through each priority, per provider.
|
||||||
|
let spentThroughPriority: Record<string, Record<number, number>> = {}
|
||||||
|
let previousSpentThroughPriority: Record<string, Record<number, number>> = {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// Returns whether a provider at a given priority still has budget headroom.
|
||||||
|
// Priority 1 always qualifies; higher priorities qualify only while everything through
|
||||||
|
// the current priority hasn't already filled the previous-minute adjusted
|
||||||
|
// budget.
|
||||||
check: async () => {
|
check: async () => {
|
||||||
const ids = tracked.map((provider) => provider.id)
|
const reads = Object.entries(maxPriorityByProvider).flatMap(([providerId, maxPriority]) =>
|
||||||
if (ids.length === 0) return {}
|
Array.from({ length: maxPriority }, (_, index) => index + 1).flatMap((priority) => [
|
||||||
const values = await redis.mget<(string | number | null)[]>(ids.map((id) => keys[id]))
|
{ providerId, priority, interval: currInterval, prev: false },
|
||||||
budgetUsage = Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)]))
|
{ providerId, priority, interval: prevInterval, prev: true },
|
||||||
return budgetUsage
|
]),
|
||||||
|
)
|
||||||
|
const values = await redis.mget<(string | number | null)[]>(
|
||||||
|
reads.map((r) => key(r.providerId, r.priority, r.interval)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const current: Record<string, Record<number, number>> = {}
|
||||||
|
const previous: Record<string, Record<number, number>> = {}
|
||||||
|
reads.forEach((r, index) => {
|
||||||
|
const amount = Number(values[index] ?? 0)
|
||||||
|
if (r.prev) {
|
||||||
|
previous[r.providerId] ??= {}
|
||||||
|
previous[r.providerId][r.priority] = amount
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current[r.providerId] ??= {}
|
||||||
|
current[r.providerId][r.priority] = amount
|
||||||
|
})
|
||||||
|
|
||||||
|
effectiveBudget = {}
|
||||||
|
spentThroughPriority = {}
|
||||||
|
previousSpentThroughPriority = {}
|
||||||
|
Object.entries(maxPriorityByProvider).forEach(([providerId, maxPriority]) => {
|
||||||
|
const providerBudget = budgetByProvider[providerId]
|
||||||
|
if (providerBudget === undefined) return
|
||||||
|
const budget = centsToMicroCents(providerBudget * 100)
|
||||||
|
|
||||||
|
let currentRunning = 0
|
||||||
|
let previousRunning = 0
|
||||||
|
effectiveBudget[providerId] = {}
|
||||||
|
spentThroughPriority[providerId] = {}
|
||||||
|
previousSpentThroughPriority[providerId] = {}
|
||||||
|
Array.from({ length: maxPriority }, (_, index) => index + 1).forEach((priority) => {
|
||||||
|
currentRunning += current[providerId]?.[priority] ?? 0
|
||||||
|
effectiveBudget[providerId][priority] = Math.max(0, budget - previousRunning)
|
||||||
|
previousRunning += previous[providerId]?.[priority] ?? 0
|
||||||
|
spentThroughPriority[providerId][priority] = currentRunning
|
||||||
|
previousSpentThroughPriority[providerId][priority] = previousRunning
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Priority 1 is unconditional. Higher priorities gate on the spend through
|
||||||
|
// the current priority against the effective budget.
|
||||||
|
qualify: (providerId: string, priority: number) => {
|
||||||
|
if (priority <= 1) return true
|
||||||
|
const budget = effectiveBudget[providerId]?.[priority]
|
||||||
|
if (budget === undefined) return false
|
||||||
|
const spentThroughCurrentPriority = spentThroughPriority[providerId]?.[priority] ?? 0
|
||||||
|
return spentThroughCurrentPriority < budget
|
||||||
|
},
|
||||||
|
prefer: (providerId: string, priority: number) => {
|
||||||
|
const providerBudget = budgetByProvider[providerId]
|
||||||
|
if (providerBudget === undefined) return false
|
||||||
|
const budget = centsToMicroCents(providerBudget * 100)
|
||||||
|
const previousUsage = previousSpentThroughPriority[providerId]?.[priority]
|
||||||
|
if (previousUsage === undefined) return false
|
||||||
|
return previousUsage < budget * 0.8
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
track: async (provider: string, costInCent: number) => {
|
track: async (provider: string, priority: number | undefined, costInCent: number) => {
|
||||||
const config = tracked.find((item) => item.id === provider)
|
if (priority === undefined) return
|
||||||
|
const config = tracked.find((item) => item.id === provider && item.budgetPriority === priority)
|
||||||
if (!config) return
|
if (!config) return
|
||||||
if (config.budgetContribution === undefined) return
|
if (config.budgetContribution === undefined) return
|
||||||
const cost = centsToMicroCents(costInCent * config.budgetContribution)
|
const cost = centsToMicroCents(costInCent * config.budgetContribution)
|
||||||
if (cost <= 0) return
|
if (cost <= 0) return
|
||||||
|
const redisKey = key(provider, priority, currInterval)
|
||||||
const pipeline = redis.pipeline()
|
const pipeline = redis.pipeline()
|
||||||
pipeline.incrby(keys[provider], cost)
|
pipeline.incrby(redisKey, cost)
|
||||||
pipeline.expire(keys[provider], 120)
|
// Keep two minutes so the previous interval is readable for budget adjustment.
|
||||||
|
pipeline.expire(redisKey, 120)
|
||||||
await pipeline.exec()
|
await pipeline.exec()
|
||||||
logger.metric({
|
logger.metric({
|
||||||
"provider.budget_usage": budgetUsage[provider] + cost,
|
"provider.budget_usage": cost,
|
||||||
"model.budget_usage": cost,
|
"provider.budget_priority": priority,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export namespace ZenData {
|
|||||||
priority: z.number().optional(),
|
priority: z.number().optional(),
|
||||||
tpmLimit: z.number().optional(),
|
tpmLimit: z.number().optional(),
|
||||||
tpsGoal: z.number().optional(),
|
tpsGoal: z.number().optional(),
|
||||||
budgetMode: z.enum(["always", "fill"]).optional(),
|
budgetPriority: z.number().optional(),
|
||||||
budgetContribution: z.number().optional(),
|
budgetContribution: z.number().optional(),
|
||||||
weight: z.number().optional(),
|
weight: z.number().optional(),
|
||||||
disabled: z.boolean().optional(),
|
disabled: z.boolean().optional(),
|
||||||
|
|||||||
@@ -86,7 +86,6 @@
|
|||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@effect/sql-sqlite-bun": "catalog:",
|
"@effect/sql-sqlite-bun": "catalog:",
|
||||||
"@lydell/node-pty": "catalog:",
|
"@lydell/node-pty": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
|
||||||
"@ff-labs/fff-bun": "0.9.4",
|
"@ff-labs/fff-bun": "0.9.4",
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@npmcli/config": "10.8.1",
|
"@npmcli/config": "10.8.1",
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export * as AgentV2 from "./agent"
|
|||||||
import { makeLocationNode } from "./effect/app-node"
|
import { makeLocationNode } from "./effect/app-node"
|
||||||
import { Array, Context, Effect, Layer, Types } from "effect"
|
import { Array, Context, Effect, Layer, Types } from "effect"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { EventV2 } from "./event"
|
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
|
|
||||||
export const ID = Agent.ID
|
export const ID = Agent.ID
|
||||||
@@ -15,8 +14,6 @@ export const Color = Agent.Color
|
|||||||
export const Info = Agent.Info
|
export const Info = Agent.Info
|
||||||
export type Info = Agent.Info
|
export type Info = Agent.Info
|
||||||
|
|
||||||
export const Event = Agent.Event
|
|
||||||
|
|
||||||
export interface Selection {
|
export interface Selection {
|
||||||
readonly id: ID
|
readonly id: ID
|
||||||
readonly info: Info | undefined
|
readonly info: Info | undefined
|
||||||
@@ -40,7 +37,7 @@ export interface Interface extends State.Transformable<Draft> {
|
|||||||
readonly default: () => Effect.Effect<Info | undefined>
|
readonly default: () => Effect.Effect<Info | undefined>
|
||||||
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
|
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
|
||||||
readonly select: (id?: ID | string) => Effect.Effect<Selection>
|
readonly select: (id?: ID | string) => Effect.Effect<Selection>
|
||||||
readonly list: () => Effect.Effect<Info[]>
|
readonly all: () => Effect.Effect<Info[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
|
||||||
@@ -48,7 +45,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const state = State.create<Data, Draft>({
|
const state = State.create<Data, Draft>({
|
||||||
initial: () => ({ agents: new Map() }),
|
initial: () => ({ agents: new Map() }),
|
||||||
draft: (draft) => ({
|
draft: (draft) => ({
|
||||||
@@ -67,7 +63,6 @@ export const layer = Layer.effect(
|
|||||||
draft.agents.delete(id)
|
draft.agents.delete(id)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
|
||||||
})
|
})
|
||||||
const selectable = (agent: Info | undefined) =>
|
const selectable = (agent: Info | undefined) =>
|
||||||
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
|
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
|
||||||
@@ -104,7 +99,7 @@ export const layer = Layer.effect(
|
|||||||
const info = selectedDefault()
|
const info = selectedDefault()
|
||||||
return { id: info?.id ?? defaultID, info }
|
return { id: info?.id ?? defaultID, info }
|
||||||
}),
|
}),
|
||||||
list: Effect.fn("AgentV2.list")(function* () {
|
all: Effect.fn("AgentV2.all")(function* () {
|
||||||
return Array.fromIterable(state.get().agents.values())
|
return Array.fromIterable(state.get().agents.values())
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -113,4 +108,4 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
export * as Job from "./job"
|
export * as BackgroundJob from "./background-job"
|
||||||
|
|
||||||
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
|
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
|
||||||
import { makeGlobalNode } from "./effect/app-node"
|
|
||||||
import { Identifier } from "./id/id"
|
import { Identifier } from "./id/id"
|
||||||
import { SessionSchema } from "./session/schema"
|
import { makeGlobalNode } from "./effect/app-node"
|
||||||
|
|
||||||
export type Status = "running" | "completed" | "error" | "cancelled"
|
export type Status = "running" | "completed" | "error" | "cancelled"
|
||||||
|
|
||||||
@@ -22,11 +21,14 @@ export type Info = {
|
|||||||
type Active = {
|
type Active = {
|
||||||
info: Info
|
info: Info
|
||||||
done: Deferred.Deferred<Info>
|
done: Deferred.Deferred<Info>
|
||||||
backgrounded: Deferred.Deferred<Info>
|
|
||||||
scope: Scope.Closeable
|
scope: Scope.Closeable
|
||||||
token: object
|
token: object
|
||||||
blockingSessions: Map<SessionSchema.ID, number>
|
pending: number
|
||||||
isBackgrounded: boolean
|
next: number
|
||||||
|
output?: { sequence: number; text: string }
|
||||||
|
tail: Deferred.Deferred<void>
|
||||||
|
promoted: Deferred.Deferred<Info>
|
||||||
|
onPromote?: Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
@@ -40,29 +42,36 @@ type FinishResult = {
|
|||||||
scope?: Scope.Closeable
|
scope?: Scope.Closeable
|
||||||
}
|
}
|
||||||
|
|
||||||
type BackgroundResult = {
|
type PromoteResult = {
|
||||||
info?: Info
|
info?: Info
|
||||||
backgrounded?: Deferred.Deferred<Info>
|
promoted?: Deferred.Deferred<Info>
|
||||||
|
onPromote?: Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
|
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
|
||||||
|
|
||||||
type BlockWait = {
|
type ExtendResult =
|
||||||
done: Deferred.Deferred<Info>
|
| { extended: false }
|
||||||
backgrounded: Deferred.Deferred<Info>
|
| {
|
||||||
}
|
extended: true
|
||||||
|
previous: Deferred.Deferred<void>
|
||||||
type BlockStart =
|
scope: Scope.Closeable
|
||||||
| { type: "missing" }
|
tail: Deferred.Deferred<void>
|
||||||
| { type: "finished"; info: Info }
|
token: object
|
||||||
| { type: "backgrounded"; info: Info }
|
sequence: number
|
||||||
| { type: "wait"; wait: BlockWait }
|
}
|
||||||
|
|
||||||
export type StartInput = {
|
export type StartInput = {
|
||||||
id?: string
|
id?: string
|
||||||
type: string
|
type: string
|
||||||
title?: string
|
title?: string
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
|
onPromote?: Effect.Effect<void>
|
||||||
|
run: Effect.Effect<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExtendInput = {
|
||||||
|
id: string
|
||||||
run: Effect.Effect<string, unknown>
|
run: Effect.Effect<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,30 +85,18 @@ export type WaitResult = {
|
|||||||
timedOut: boolean
|
timedOut: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BlockInput = {
|
|
||||||
id: string
|
|
||||||
sessionID: SessionSchema.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BlockResult = { type: "finished"; info: Info } | { type: "backgrounded"; info: Info }
|
|
||||||
|
|
||||||
export type BackgroundAllInput = {
|
|
||||||
sessionID: SessionSchema.ID
|
|
||||||
type?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly list: () => Effect.Effect<Info[]>
|
readonly list: () => Effect.Effect<Info[]>
|
||||||
readonly get: (id: string) => Effect.Effect<Info | undefined>
|
readonly get: (id: string) => Effect.Effect<Info | undefined>
|
||||||
readonly start: (input: StartInput) => Effect.Effect<Info>
|
readonly start: (input: StartInput) => Effect.Effect<Info>
|
||||||
|
readonly extend: (input: ExtendInput) => Effect.Effect<boolean>
|
||||||
readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
|
readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
|
||||||
readonly block: (input: BlockInput) => Effect.Effect<BlockResult | undefined>
|
readonly waitForPromotion: (id: string) => Effect.Effect<Info>
|
||||||
readonly background: (id: string) => Effect.Effect<Info | undefined>
|
readonly promote: (id: string) => Effect.Effect<Info | undefined>
|
||||||
readonly backgroundAll: (input: BackgroundAllInput) => Effect.Effect<Info[]>
|
|
||||||
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
|
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Job") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/BackgroundJob") {}
|
||||||
|
|
||||||
function snapshot(job: Active): Info {
|
function snapshot(job: Active): Info {
|
||||||
return {
|
return {
|
||||||
@@ -113,19 +110,6 @@ function errorText(error: unknown) {
|
|||||||
return String(error)
|
return String(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
function incrementSession(input: Map<SessionSchema.ID, number>, sessionID: SessionSchema.ID) {
|
|
||||||
return new Map(input).set(sessionID, (input.get(sessionID) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: SessionSchema.ID) {
|
|
||||||
const count = input.get(sessionID)
|
|
||||||
if (count === undefined) return input
|
|
||||||
const next = new Map(input)
|
|
||||||
if (count <= 1) next.delete(sessionID)
|
|
||||||
else next.set(sessionID, count - 1)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes one scoped, process-local registry. Entries are intentionally not
|
* Makes one scoped, process-local registry. Entries are intentionally not
|
||||||
* durable: process restart or owner-scope closure loses status and interrupts
|
* durable: process restart or owner-scope closure loses status and interrupts
|
||||||
@@ -139,13 +123,26 @@ export const make = Effect.gen(function* () {
|
|||||||
scope: yield* Scope.Scope,
|
scope: yield* Scope.Scope,
|
||||||
}
|
}
|
||||||
|
|
||||||
const settle = Effect.fn("Job.settle")(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
|
const settle = Effect.fn("BackgroundJob.settle")(function* (
|
||||||
|
id: string,
|
||||||
|
token: object,
|
||||||
|
sequence: number,
|
||||||
|
exit: Exit.Exit<string, unknown>,
|
||||||
|
) {
|
||||||
const completed_at = yield* Clock.currentTimeMillis
|
const completed_at = yield* Clock.currentTimeMillis
|
||||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
||||||
const job = jobs.get(id)
|
const job = jobs.get(id)
|
||||||
if (!job) return [{}, jobs]
|
if (!job) return [{}, jobs]
|
||||||
if (job.token !== token) return [{}, jobs]
|
if (job.token !== token) return [{}, jobs]
|
||||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||||
|
const pending = job.pending - 1
|
||||||
|
const output =
|
||||||
|
Exit.isSuccess(exit) && (!job.output || sequence > job.output.sequence)
|
||||||
|
? { sequence, text: exit.value }
|
||||||
|
: job.output
|
||||||
|
if (Exit.isSuccess(exit) && pending > 0) {
|
||||||
|
return [{}, new Map(jobs).set(id, { ...job, pending, output })]
|
||||||
|
}
|
||||||
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
|
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
|
||||||
? "completed"
|
? "completed"
|
||||||
: Cause.hasInterruptsOnly(exit.cause)
|
: Cause.hasInterruptsOnly(exit.cause)
|
||||||
@@ -153,12 +150,14 @@ export const make = Effect.gen(function* () {
|
|||||||
: "error"
|
: "error"
|
||||||
const next = {
|
const next = {
|
||||||
...job,
|
...job,
|
||||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
onPromote: undefined,
|
||||||
|
pending: 0,
|
||||||
|
output,
|
||||||
info: {
|
info: {
|
||||||
...job.info,
|
...job.info,
|
||||||
status,
|
status,
|
||||||
completed_at,
|
completed_at,
|
||||||
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
|
...(output ? { output: output.text } : {}),
|
||||||
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
|
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -171,41 +170,43 @@ export const make = Effect.gen(function* () {
|
|||||||
return result.info
|
return result.info
|
||||||
})
|
})
|
||||||
|
|
||||||
const fork = Effect.fn("Job.fork")(function* (
|
const fork = Effect.fn("BackgroundJob.fork")(function* (
|
||||||
scope: Scope.Scope,
|
scope: Scope.Scope,
|
||||||
id: string,
|
id: string,
|
||||||
token: object,
|
token: object,
|
||||||
|
sequence: number,
|
||||||
run: Effect.Effect<string, unknown>,
|
run: Effect.Effect<string, unknown>,
|
||||||
) {
|
) {
|
||||||
return yield* run.pipe(
|
return yield* run.pipe(
|
||||||
Effect.matchCauseEffect({
|
Effect.matchCauseEffect({
|
||||||
onSuccess: (output) => settle(id, token, Exit.succeed(output)),
|
onSuccess: (output) => settle(id, token, sequence, Exit.succeed(output)),
|
||||||
onFailure: (cause) => settle(id, token, Exit.failCause(cause)),
|
onFailure: (cause) => settle(id, token, sequence, Exit.failCause(cause)),
|
||||||
}),
|
}),
|
||||||
Effect.asVoid,
|
Effect.asVoid,
|
||||||
Effect.forkIn(scope, { startImmediately: true }),
|
Effect.forkIn(scope, { startImmediately: true }),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const list: Interface["list"] = Effect.fn("Job.list")(function* () {
|
const list: Interface["list"] = Effect.fn("BackgroundJob.list")(function* () {
|
||||||
return Array.from((yield* SynchronizedRef.get(state.jobs)).values())
|
return Array.from((yield* SynchronizedRef.get(state.jobs)).values())
|
||||||
.map(snapshot)
|
.map(snapshot)
|
||||||
.toSorted((a, b) => a.started_at - b.started_at)
|
.toSorted((a, b) => a.started_at - b.started_at)
|
||||||
})
|
})
|
||||||
|
|
||||||
const get: Interface["get"] = Effect.fn("Job.get")(function* (id) {
|
const get: Interface["get"] = Effect.fn("BackgroundJob.get")(function* (id) {
|
||||||
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
|
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
|
||||||
if (!job) return undefined
|
if (!job) return
|
||||||
return snapshot(job)
|
return snapshot(job)
|
||||||
})
|
})
|
||||||
|
|
||||||
const start: Interface["start"] = Effect.fn("Job.start")(function* (input) {
|
const start: Interface["start"] = Effect.fn("BackgroundJob.start")(function* (input) {
|
||||||
return yield* Effect.uninterruptibleMask((restore) =>
|
return yield* Effect.uninterruptibleMask((restore) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = input.id ?? Identifier.ascending("job")
|
const id = input.id ?? Identifier.ascending("job")
|
||||||
const started_at = yield* Clock.currentTimeMillis
|
const started_at = yield* Clock.currentTimeMillis
|
||||||
const done = yield* Deferred.make<Info>()
|
const done = yield* Deferred.make<Info>()
|
||||||
const backgrounded = yield* Deferred.make<Info>()
|
const promoted = yield* Deferred.make<Info>()
|
||||||
|
const tail = yield* Deferred.make<void>()
|
||||||
const result = yield* SynchronizedRef.modifyEffect(
|
const result = yield* SynchronizedRef.modifyEffect(
|
||||||
state.jobs,
|
state.jobs,
|
||||||
Effect.fnUntraced(function* (jobs) {
|
Effect.fnUntraced(function* (jobs) {
|
||||||
@@ -225,11 +226,13 @@ export const make = Effect.gen(function* () {
|
|||||||
metadata: input.metadata,
|
metadata: input.metadata,
|
||||||
},
|
},
|
||||||
done,
|
done,
|
||||||
backgrounded,
|
|
||||||
scope,
|
scope,
|
||||||
token,
|
token,
|
||||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
pending: 1,
|
||||||
isBackgrounded: false,
|
next: 1,
|
||||||
|
tail,
|
||||||
|
promoted,
|
||||||
|
onPromote: input.onPromote,
|
||||||
}
|
}
|
||||||
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
|
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
|
||||||
StartResult,
|
StartResult,
|
||||||
@@ -237,13 +240,56 @@ export const make = Effect.gen(function* () {
|
|||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if ("scope" in result) yield* fork(result.scope, id, result.token, restore(input.run))
|
if ("scope" in result)
|
||||||
|
yield* fork(
|
||||||
|
result.scope,
|
||||||
|
id,
|
||||||
|
result.token,
|
||||||
|
0,
|
||||||
|
restore(input.run).pipe(Effect.ensuring(Deferred.succeed(tail, undefined))),
|
||||||
|
)
|
||||||
return result.info
|
return result.info
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const wait: Interface["wait"] = Effect.fn("Job.wait")(function* (input) {
|
const extend: Interface["extend"] = Effect.fn("BackgroundJob.extend")(function* (input) {
|
||||||
|
return yield* Effect.uninterruptibleMask((restore) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const tail = yield* Deferred.make<void>()
|
||||||
|
const result = yield* SynchronizedRef.modify(
|
||||||
|
state.jobs,
|
||||||
|
(jobs): readonly [ExtendResult, Map<string, Active>] => {
|
||||||
|
const job = jobs.get(input.id)
|
||||||
|
if (!job || job.info.status !== "running") return [{ extended: false }, jobs]
|
||||||
|
return [
|
||||||
|
{ extended: true, previous: job.tail, scope: job.scope, tail, token: job.token, sequence: job.next },
|
||||||
|
new Map(jobs).set(input.id, {
|
||||||
|
...job,
|
||||||
|
pending: job.pending + 1,
|
||||||
|
next: job.next + 1,
|
||||||
|
tail,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!result.extended) return false
|
||||||
|
yield* fork(
|
||||||
|
result.scope,
|
||||||
|
input.id,
|
||||||
|
result.token,
|
||||||
|
result.sequence,
|
||||||
|
Deferred.await(result.previous).pipe(
|
||||||
|
Effect.andThen(restore(input.run)),
|
||||||
|
Effect.ensuring(Deferred.succeed(result.tail, undefined)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const wait: Interface["wait"] = Effect.fn("BackgroundJob.wait")(function* (input) {
|
||||||
const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
|
const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
|
||||||
if (!job) return { timedOut: false }
|
if (!job) return { timedOut: false }
|
||||||
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
|
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
|
||||||
@@ -254,91 +300,41 @@ export const make = Effect.gen(function* () {
|
|||||||
return { info: snapshot(job), timedOut: true }
|
return { info: snapshot(job), timedOut: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeBlock = Effect.fn("Job.removeBlock")(function* (input: BlockInput) {
|
const waitForPromotion: Interface["waitForPromotion"] = Effect.fn("BackgroundJob.waitForPromotion")(function* (id) {
|
||||||
yield* SynchronizedRef.update(state.jobs, (jobs) => {
|
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
|
||||||
const job = jobs.get(input.id)
|
if (!job || job.info.status !== "running") return yield* Effect.never
|
||||||
if (!job || job.info.status !== "running" || job.isBackgrounded) return jobs
|
if (job.info.metadata?.background === true) return snapshot(job)
|
||||||
return new Map(jobs).set(input.id, {
|
return yield* Deferred.await(job.promoted)
|
||||||
...job,
|
|
||||||
blockingSessions: decrementSession(job.blockingSessions, input.sessionID),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const block: Interface["block"] = Effect.fn("Job.block")(function* (input) {
|
const promote: Interface["promote"] = Effect.fn("BackgroundJob.promote")(function* (id) {
|
||||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
|
const result = yield* SynchronizedRef.modifyEffect(
|
||||||
const job = jobs.get(input.id)
|
|
||||||
if (!job) return [{ type: "missing" }, jobs]
|
|
||||||
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job) }, jobs]
|
|
||||||
if (job.isBackgrounded) return [{ type: "backgrounded", info: snapshot(job) }, jobs]
|
|
||||||
return [
|
|
||||||
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded } },
|
|
||||||
new Map(jobs).set(input.id, {
|
|
||||||
...job,
|
|
||||||
blockingSessions: incrementSession(job.blockingSessions, input.sessionID),
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
})
|
|
||||||
if (result.type === "missing") return undefined
|
|
||||||
if (result.type === "finished") return { type: "finished", info: result.info }
|
|
||||||
if (result.type === "backgrounded") return { type: "backgrounded", info: result.info }
|
|
||||||
return yield* Effect.raceFirst(
|
|
||||||
Deferred.await(result.wait.done).pipe(Effect.map((info) => ({ type: "finished" as const, info }))),
|
|
||||||
Deferred.await(result.wait.backgrounded).pipe(Effect.map((info) => ({ type: "backgrounded" as const, info }))),
|
|
||||||
).pipe(Effect.ensuring(removeBlock(input)))
|
|
||||||
})
|
|
||||||
|
|
||||||
const background: Interface["background"] = Effect.fn("Job.background")(function* (id) {
|
|
||||||
const result = yield* SynchronizedRef.modify(
|
|
||||||
state.jobs,
|
state.jobs,
|
||||||
(jobs): readonly [BackgroundResult, Map<string, Active>] => {
|
Effect.fnUntraced(function* (jobs) {
|
||||||
const job = jobs.get(id)
|
const job = jobs.get(id)
|
||||||
if (!job || job.info.status !== "running") return [{}, jobs]
|
if (!job || job.info.status !== "running") return [{}, jobs] as readonly [PromoteResult, Map<string, Active>]
|
||||||
if (job.isBackgrounded) return [{ info: snapshot(job) }, jobs]
|
if (job.info.metadata?.background === true)
|
||||||
|
return [{ info: snapshot(job) }, jobs] as readonly [PromoteResult, Map<string, Active>]
|
||||||
const next = {
|
const next = {
|
||||||
...job,
|
...job,
|
||||||
isBackgrounded: true,
|
onPromote: undefined,
|
||||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
info: {
|
||||||
|
...job.info,
|
||||||
|
metadata: { ...job.info.metadata, background: true },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)]
|
return [
|
||||||
},
|
{ info: snapshot(next), onPromote: job.onPromote, promoted: job.promoted },
|
||||||
|
new Map(jobs).set(id, next),
|
||||||
|
] as readonly [PromoteResult, Map<string, Active>]
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
if (result.info && result.backgrounded)
|
if (result.info && result.promoted) yield* Deferred.succeed(result.promoted, result.info).pipe(Effect.ignore)
|
||||||
yield* Deferred.succeed(result.backgrounded, result.info).pipe(Effect.ignore)
|
if (result.onPromote) yield* result.onPromote.pipe(Effect.ignore)
|
||||||
return result.info
|
return result.info
|
||||||
})
|
})
|
||||||
|
|
||||||
const backgroundAll: Interface["backgroundAll"] = Effect.fn("Job.backgroundAll")(function* (input) {
|
const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) {
|
||||||
const result = yield* SynchronizedRef.modify(
|
|
||||||
state.jobs,
|
|
||||||
(jobs): readonly [BackgroundResult[], Map<string, Active>] => {
|
|
||||||
const results: BackgroundResult[] = []
|
|
||||||
const next = new Map(jobs)
|
|
||||||
for (const [id, job] of jobs) {
|
|
||||||
if (job.info.status !== "running") continue
|
|
||||||
if (job.isBackgrounded) continue
|
|
||||||
if (input.type !== undefined && job.info.type !== input.type) continue
|
|
||||||
if (!job.blockingSessions.has(input.sessionID)) continue
|
|
||||||
const updated = {
|
|
||||||
...job,
|
|
||||||
isBackgrounded: true,
|
|
||||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
|
||||||
}
|
|
||||||
results.push({ info: snapshot(updated), backgrounded: job.backgrounded })
|
|
||||||
next.set(id, updated)
|
|
||||||
}
|
|
||||||
return [results, next]
|
|
||||||
},
|
|
||||||
)
|
|
||||||
yield* Effect.forEach(
|
|
||||||
result,
|
|
||||||
(item) => (item.info && item.backgrounded ? Deferred.succeed(item.backgrounded, item.info) : Effect.void),
|
|
||||||
{ discard: true },
|
|
||||||
)
|
|
||||||
return result.flatMap((item) => (item.info ? [item.info] : []))
|
|
||||||
})
|
|
||||||
|
|
||||||
const cancel: Interface["cancel"] = Effect.fn("Job.cancel")(function* (id) {
|
|
||||||
const completed_at = yield* Clock.currentTimeMillis
|
const completed_at = yield* Clock.currentTimeMillis
|
||||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
||||||
const job = jobs.get(id)
|
const job = jobs.get(id)
|
||||||
@@ -346,7 +342,8 @@ export const make = Effect.gen(function* () {
|
|||||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||||
const next = {
|
const next = {
|
||||||
...job,
|
...job,
|
||||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
onPromote: undefined,
|
||||||
|
pending: 0,
|
||||||
info: {
|
info: {
|
||||||
...job.info,
|
...job.info,
|
||||||
status: "cancelled" as const,
|
status: "cancelled" as const,
|
||||||
@@ -360,7 +357,7 @@ export const make = Effect.gen(function* () {
|
|||||||
return result.info
|
return result.info
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ list, get, start, wait, block, background, backgroundAll, cancel })
|
return Service.of({ list, get, start, extend, wait, waitForPromotion, promote, cancel })
|
||||||
})
|
})
|
||||||
|
|
||||||
export const layer = Layer.effect(Service, make)
|
export const layer = Layer.effect(Service, make)
|
||||||
@@ -139,7 +139,7 @@ export const layer = Layer.effect(
|
|||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const policy = yield* Policy.Service
|
const policy = yield* Policy.Service
|
||||||
const names = ["opencode.json", "opencode.jsonc"]
|
const names = ["config.json", "opencode.json", "opencode.jsonc"]
|
||||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||||
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
||||||
|
|||||||
@@ -29,12 +29,6 @@ const PluginModule = Schema.Struct({
|
|||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
|
|
||||||
const PluginPackage = Schema.Struct({
|
|
||||||
exports: Schema.optional(Schema.Unknown),
|
|
||||||
main: Schema.optional(Schema.String),
|
|
||||||
module: Schema.optional(Schema.String),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Plugin = define({
|
export const Plugin = define({
|
||||||
id: "config-plugin",
|
id: "config-plugin",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
@@ -71,30 +65,8 @@ export const Plugin = define({
|
|||||||
symlink: true,
|
symlink: true,
|
||||||
})
|
})
|
||||||
.pipe(Effect.orElseSucceed(() => []))
|
.pipe(Effect.orElseSucceed(() => []))
|
||||||
const directories = yield* fs
|
|
||||||
.glob("{plugin,plugins}/*", {
|
|
||||||
cwd: entry.path,
|
|
||||||
absolute: true,
|
|
||||||
include: "all",
|
|
||||||
dot: true,
|
|
||||||
symlink: true,
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
Effect.flatMap((items) =>
|
|
||||||
Effect.filter(items, (item) => fs.isDir(item), {
|
|
||||||
concurrency: "unbounded",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Effect.orElseSucceed(() => []),
|
|
||||||
)
|
|
||||||
const packages = yield* Effect.forEach(
|
|
||||||
directories.sort(),
|
|
||||||
(directory) => resolvePackageEntrypoint(fs, directory),
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined)))
|
|
||||||
files.sort()
|
files.sort()
|
||||||
for (const file of files) configured.push({ package: file })
|
for (const file of files) configured.push({ package: file })
|
||||||
for (const file of packages) configured.push({ package: file })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +76,7 @@ export const Plugin = define({
|
|||||||
? pathToFileURL(ref.package).href
|
? pathToFileURL(ref.package).href
|
||||||
: (yield* npm.add(ref.package)).entrypoint
|
: (yield* npm.add(ref.package)).entrypoint
|
||||||
if (!entrypoint) return
|
if (!entrypoint) return
|
||||||
yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint })
|
|
||||||
const mod = yield* Effect.promise(() => import(entrypoint))
|
const mod = yield* Effect.promise(() => import(entrypoint))
|
||||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||||
@@ -114,21 +86,6 @@ export const Plugin = define({
|
|||||||
})
|
})
|
||||||
}).pipe(Effect.ignoreCause)
|
}).pipe(Effect.ignoreCause)
|
||||||
}
|
}
|
||||||
})
|
}).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
|
|
||||||
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
|
|
||||||
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
|
|
||||||
Effect.catch(() => Effect.succeed(undefined)),
|
|
||||||
)
|
|
||||||
const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined
|
|
||||||
const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"]
|
|
||||||
|
|
||||||
return yield* Effect.forEach(entries, (entry) => {
|
|
||||||
if (!entry) return Effect.succeed(undefined)
|
|
||||||
const file = path.resolve(directory, entry)
|
|
||||||
return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined)))
|
|
||||||
}).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined)))
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * as MoveSession from "./move-session"
|
export * as MoveSession from "./move-session"
|
||||||
|
|
||||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||||
|
import { makeGlobalNode } from "../effect/app-node"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { Git } from "../git"
|
import { Git } from "../git"
|
||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
@@ -146,3 +147,9 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(ProjectV2.defaultLayer),
|
Layer.provide(ProjectV2.defaultLayer),
|
||||||
Layer.provide(SessionStore.defaultLayer),
|
Layer.provide(SessionStore.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
import { Layer } from "effect"
|
|
||||||
import { buildLocationServiceMap } from "../location-services"
|
import { buildLocationServiceMap } from "../location-services"
|
||||||
import { LocationServiceMap } from "../location-service-map"
|
import { LocationServiceMap } from "../location-service-map"
|
||||||
import { LayerNode } from "./layer-node"
|
import { LayerNode } from "./layer-node"
|
||||||
import { makeGlobalNode } from "./app-node"
|
import { makeGlobalNode } from "./app-node"
|
||||||
|
|
||||||
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) {
|
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
|
||||||
const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement]))
|
let allReplacements = replacements
|
||||||
|
|
||||||
if (!LayerNode.hasUnbound(root, LocationServiceMap.node)) {
|
// Only build the location service map if it's actually needed
|
||||||
// If the location service map is not needed, we shouldn't pull it
|
if (LayerNode.hasUnbound(root, LocationServiceMap.node) && !hasReplacement(replacements, LocationServiceMap.node)) {
|
||||||
// in. Compile the graph normally
|
const locationMap = buildLocationServiceMap(replacements)
|
||||||
return LayerNode.compile(root, replacementMap)
|
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
|
||||||
|
allReplacements = replacements.concat([[LocationServiceMap.node, locationMapNode]])
|
||||||
}
|
}
|
||||||
|
|
||||||
const locationMap = buildLocationServiceMap(replacementMap)
|
return LayerNode.compile(root, allReplacements)
|
||||||
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
|
}
|
||||||
|
|
||||||
const app = LayerNode.bind(root, LocationServiceMap.node, locationMapNode)
|
function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node<unknown, unknown, any>) {
|
||||||
|
return replacements.some(([source]) => source.name === node.name)
|
||||||
return LayerNode.compile(app, replacementMap)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as AppNodeBuilder from "./app-node-builder"
|
export * as AppNodeBuilder from "./app-node-builder"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
File to save in: ~/.local/share/opencode/worktree/012780/location-layer-tiers/packages/core/src/effect/
|
||||||
@@ -111,20 +111,52 @@ export function group<const Items extends readonly AnyNode[]>(
|
|||||||
return { kind: "group", name: "group", dependencies }
|
return { kind: "group", name: "group", dependencies }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Replacement = {
|
export type Replacement = readonly [source: AnyNode, replacement: AnyNode | Layer.Any]
|
||||||
readonly source: Layer.Any
|
export type Replacements = readonly Replacement[]
|
||||||
readonly replacement: Layer.Any
|
|
||||||
}
|
|
||||||
|
|
||||||
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
|
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
|
||||||
? unknown
|
? unknown
|
||||||
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
|
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
|
||||||
|
|
||||||
export function replace<A, E, R, E2>(
|
type CheckReplacement<Item> = Item extends readonly [Node<infer A, infer E, infer T>, infer Replacement]
|
||||||
source: Layer.Layer<A, E, R>,
|
? Replacement extends Node<NoInfer<A>, infer E2, T>
|
||||||
replacement: Layer.Layer<NoInfer<A>, E2, NoInfer<R>> & CheckReplacementErrors<E, NoInfer<E2>>,
|
? CheckReplacementErrors<E, NoInfer<E2>>
|
||||||
): Replacement {
|
: Replacement extends Layer.Layer<NoInfer<A>, infer E2, never>
|
||||||
return { source, replacement }
|
? CheckReplacementErrors<E, NoInfer<E2>>
|
||||||
|
: { readonly "Invalid replacement": Replacement }
|
||||||
|
: { readonly "Invalid replacement": Item }
|
||||||
|
|
||||||
|
type CheckReplacements<Items extends Replacements> = {
|
||||||
|
readonly [K in keyof Items]: CheckReplacement<Items[K]>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidReplacements<Items extends Replacements> = Items & CheckReplacements<Items>
|
||||||
|
|
||||||
|
function replacementNode(source: AnyNode, replacement: AnyNode | Layer.Any) {
|
||||||
|
const replacementNode = isNode(replacement)
|
||||||
|
? replacement
|
||||||
|
: make({
|
||||||
|
...nodeMakeIdentity(source),
|
||||||
|
layer: replacement as Layer.Layer<unknown, unknown>,
|
||||||
|
deps: [],
|
||||||
|
tag: source.tag,
|
||||||
|
})
|
||||||
|
if (source.name !== replacementNode.name) {
|
||||||
|
throw new Error(`Cannot replace ${source.name} with ${replacementNode.name}`)
|
||||||
|
}
|
||||||
|
if (source.tag !== replacementNode.tag) {
|
||||||
|
throw new Error(`Cannot replace ${source.name} across tags`)
|
||||||
|
}
|
||||||
|
return replacementNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeMakeIdentity(node: AnyNode): NodeIdentity {
|
||||||
|
if (node.service !== undefined) return { service: node.service }
|
||||||
|
return { name: node.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNode(input: Layer.Any | AnyNode): input is AnyNode {
|
||||||
|
return "kind" in input && "dependencies" in input
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tree -----------------------------------------------------------------------
|
// Tree -----------------------------------------------------------------------
|
||||||
@@ -176,32 +208,38 @@ function walk<Result>(
|
|||||||
return recur(root)
|
return recur(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hoist<A, E, T extends Tag>(
|
export function hoist<A, E, T extends Tag, const Items extends Replacements = readonly []>(
|
||||||
root: Node<A, E, any>,
|
root: Node<A, E, any>,
|
||||||
tag: T,
|
tag: T,
|
||||||
|
replacements?: ValidReplacements<Items>,
|
||||||
): {
|
): {
|
||||||
readonly node: Node<A, E>
|
readonly node: Node<A, E>
|
||||||
readonly hoisted: Node<unknown, E>
|
readonly hoisted: Node<unknown, E>
|
||||||
} {
|
} {
|
||||||
const hoisted = new Map<string, AnyNode>()
|
const hoisted = new Map<string, AnyNode>()
|
||||||
|
const replacementMap = replacementMapFrom(replacements)
|
||||||
|
|
||||||
const node = walk<AnyNode>(root, (node, context) => {
|
const node = walk<AnyNode>(
|
||||||
if (node.kind === "group") {
|
root,
|
||||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
(node, context) => {
|
||||||
}
|
if (node.kind === "group") {
|
||||||
if (node.tag === tag) {
|
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||||
const existing = hoisted.get(node.name)
|
|
||||||
if (existing && existing !== node) {
|
|
||||||
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
|
|
||||||
}
|
}
|
||||||
hoisted.set(node.name, node)
|
if (node.tag === tag) {
|
||||||
return group([])
|
const existing = hoisted.get(node.name)
|
||||||
}
|
if (existing && existing !== node) {
|
||||||
if (node.kind === "unbound") {
|
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
|
||||||
return node
|
}
|
||||||
}
|
hoisted.set(node.name, node)
|
||||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
return group([])
|
||||||
})
|
}
|
||||||
|
if (node.kind === "unbound") {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||||
|
},
|
||||||
|
{ resolve: (node) => replacementMap.get(node.name) ?? node },
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
node: node as Node<A, E>,
|
node: node as Node<A, E>,
|
||||||
@@ -209,10 +247,11 @@ export function hoist<A, E, T extends Tag>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compile<A, E>(
|
export function compile<A, E, const Items extends Replacements = readonly []>(
|
||||||
root: Node<A, E, any>,
|
root: Node<A, E, any>,
|
||||||
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
|
replacements?: ValidReplacements<Items>,
|
||||||
): Layer.Layer<A, E> {
|
): Layer.Layer<A, E> {
|
||||||
|
const replacementMap = replacementMapFrom(replacements)
|
||||||
const cache = new Map<AnyNode, RuntimeLayer>()
|
const cache = new Map<AnyNode, RuntimeLayer>()
|
||||||
const compileNode = (node: AnyNode) =>
|
const compileNode = (node: AnyNode) =>
|
||||||
walk<RuntimeLayer>(
|
walk<RuntimeLayer>(
|
||||||
@@ -220,18 +259,65 @@ export function compile<A, E>(
|
|||||||
(node, context) => {
|
(node, context) => {
|
||||||
if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`)
|
if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`)
|
||||||
const dependencies = node.dependencies.flatMap(flatten).map(context.visit)
|
const dependencies = node.dependencies.flatMap(flatten).map(context.visit)
|
||||||
const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer
|
const implementation = node.implementation! as RuntimeLayer
|
||||||
return dependencies.length === 0
|
return dependencies.length === 0
|
||||||
? implementation
|
? implementation
|
||||||
: implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
|
: implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
|
||||||
},
|
},
|
||||||
{ cache },
|
{ cache, resolve: (node) => replacementMap.get(node.name) ?? node },
|
||||||
)
|
)
|
||||||
const layers = flatten(root).map((node) => compileNode(node))
|
const layers = flatten(root).map((node) => compileNode(node))
|
||||||
const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
|
const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
|
||||||
return layer as Layer.Layer<A, E>
|
return layer as Layer.Layer<A, E>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replacementMapFrom(replacements?: Replacements) {
|
||||||
|
return (
|
||||||
|
replacements?.reduce((map, [source, replacement]) => {
|
||||||
|
const normalized = rewriteReplacementDependencies(replacementNode(source, replacement), map)
|
||||||
|
const current = new Map([[source.name, normalized]])
|
||||||
|
for (const [name, node] of map) map.set(name, rewriteReplacementDependencies(node, current))
|
||||||
|
map.set(source.name, normalized)
|
||||||
|
return map
|
||||||
|
}, new Map<string, AnyNode>()) ?? new Map<string, AnyNode>()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteReplacementDependencies(root: AnyNode, replacements: ReadonlyMap<string, AnyNode>) {
|
||||||
|
if (replacements.size === 0) return root
|
||||||
|
const cache = new Map<AnyNode, AnyNode>()
|
||||||
|
const visiting = new Set<AnyNode>()
|
||||||
|
const stack: AnyNode[] = []
|
||||||
|
|
||||||
|
const recur = (node: AnyNode, isRoot = false): AnyNode => {
|
||||||
|
const target = isRoot ? node : (replacements.get(node.name) ?? node)
|
||||||
|
const cached = cache.get(target)
|
||||||
|
if (cached !== undefined || cache.has(target)) return cached!
|
||||||
|
if (visiting.has(target)) {
|
||||||
|
const start = stack.indexOf(target)
|
||||||
|
throw new Error(
|
||||||
|
`Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
visiting.add(target)
|
||||||
|
stack.push(target)
|
||||||
|
try {
|
||||||
|
const dependencies = target.dependencies.map((dependency) => recur(dependency))
|
||||||
|
const result = dependencies.every((dependency, index) => dependency === target.dependencies[index])
|
||||||
|
? target
|
||||||
|
: { ...target, dependencies }
|
||||||
|
cache.set(target, result)
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
stack.pop()
|
||||||
|
visiting.delete(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return recur(root, true)
|
||||||
|
}
|
||||||
|
|
||||||
export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
|
export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
|
||||||
if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`)
|
if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`)
|
||||||
return walk<boolean>(root, (node, context) => {
|
return walk<boolean>(root, (node, context) => {
|
||||||
@@ -240,32 +326,6 @@ export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode):
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bind<A, E, T extends Tag | undefined>(
|
|
||||||
root: Node<A, E, T>,
|
|
||||||
source: AnyNode,
|
|
||||||
replacement: AnyNode,
|
|
||||||
): Node<A, E, T> {
|
|
||||||
if (source.kind !== "unbound") throw new Error(`Cannot bind non-unbound layer node: ${source.name}`)
|
|
||||||
if (source.name !== replacement.name) {
|
|
||||||
throw new Error(`Cannot bind ${source.name} to ${replacement.name}`)
|
|
||||||
}
|
|
||||||
if (source.tag !== replacement.tag) {
|
|
||||||
throw new Error(`Cannot bind ${source.name} across tags`)
|
|
||||||
}
|
|
||||||
return walk<AnyNode>(
|
|
||||||
root,
|
|
||||||
(target, context) => {
|
|
||||||
if (target.kind === "unbound") return target
|
|
||||||
const dependencies: AnyNode[] = []
|
|
||||||
const clone = { ...target, dependencies }
|
|
||||||
context.cache.set(target, clone)
|
|
||||||
dependencies.push(...target.dependencies.map(context.visit))
|
|
||||||
return clone
|
|
||||||
},
|
|
||||||
{ detectCycles: false, resolve: (node) => (node === source ? replacement : node) },
|
|
||||||
) as Node<A, E, T>
|
|
||||||
}
|
|
||||||
|
|
||||||
function flatten(node: AnyNode): readonly AnyNode[] {
|
function flatten(node: AnyNode): readonly AnyNode[] {
|
||||||
return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node]
|
return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export * as EventV2 from "./event"
|
|||||||
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||||
import { and, asc, eq, gt, inArray, sql } from "drizzle-orm"
|
import { and, asc, eq, gt, inArray } from "drizzle-orm"
|
||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
@@ -31,22 +31,6 @@ export const latestSequence = Effect.fn("EventV2.latestSequence")(function* (
|
|||||||
return row?.seq ?? -1
|
return row?.seq ?? -1
|
||||||
})
|
})
|
||||||
|
|
||||||
export const reserveSequence = Effect.fn("EventV2.reserveSequence")(function* (
|
|
||||||
db: Database.Interface["db"],
|
|
||||||
aggregateID: string,
|
|
||||||
seq: number,
|
|
||||||
) {
|
|
||||||
yield* db
|
|
||||||
.insert(EventSequenceTable)
|
|
||||||
.values([{ aggregate_id: aggregateID, seq }])
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: EventSequenceTable.aggregate_id,
|
|
||||||
set: { seq: sql`max(${EventSequenceTable.seq}, ${seq})` },
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
})
|
|
||||||
|
|
||||||
export type SerializedEvent = {
|
export type SerializedEvent = {
|
||||||
readonly id: ID
|
readonly id: ID
|
||||||
readonly type: string
|
readonly type: string
|
||||||
@@ -343,7 +327,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: EventSequenceTable.aggregate_id,
|
target: EventSequenceTable.aggregate_id,
|
||||||
set: {
|
set: {
|
||||||
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
|
seq,
|
||||||
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
|
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
export * as Generate from "./generate"
|
|
||||||
|
|
||||||
import { LLM, LLMClient, LLMError } from "@opencode-ai/llm"
|
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
|
||||||
import { Catalog } from "./catalog"
|
|
||||||
import { makeLocationNode } from "./effect/app-node"
|
|
||||||
import { llmClient } from "./effect/app-node-platform"
|
|
||||||
import { Integration } from "./integration"
|
|
||||||
import { ModelV2 } from "./model"
|
|
||||||
import { SessionRunnerModel } from "./session/runner/model"
|
|
||||||
|
|
||||||
export interface TextInput {
|
|
||||||
readonly prompt: string
|
|
||||||
readonly model?: ModelV2.Ref
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ModelSelectionError extends Schema.TaggedErrorClass<ModelSelectionError>()(
|
|
||||||
"Generate.ModelSelectionError",
|
|
||||||
{ message: Schema.String },
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class UnavailableError extends Schema.TaggedErrorClass<UnavailableError>()(
|
|
||||||
"Generate.UnavailableError",
|
|
||||||
{ message: Schema.String, service: Schema.optional(Schema.String) },
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export type Error = ModelSelectionError | UnavailableError
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly text: (input: TextInput) => Effect.Effect<string, Error>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Generate") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
const integrations = yield* Integration.Service
|
|
||||||
const llm = yield* LLMClient.Service
|
|
||||||
|
|
||||||
const selectModel = Effect.fn("Generate.selectModel")(function* (requested?: ModelV2.Ref) {
|
|
||||||
const selected = requested
|
|
||||||
? yield* catalog.model.get(requested.providerID, requested.id)
|
|
||||||
: yield* catalog.model.default().pipe(
|
|
||||||
Effect.flatMap((model) =>
|
|
||||||
model && SessionRunnerModel.supported(model)
|
|
||||||
? Effect.succeed(model)
|
|
||||||
: Effect.map(catalog.model.available(), (models) => models.find(SessionRunnerModel.supported)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!selected)
|
|
||||||
return yield* new ModelSelectionError({
|
|
||||||
message: requested
|
|
||||||
? `Model unavailable: ${requested.providerID}/${requested.id}`
|
|
||||||
: "No model specified and no supported model is available",
|
|
||||||
})
|
|
||||||
return yield* SessionRunnerModel.withVariant(selected, requested?.variant).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
() =>
|
|
||||||
new ModelSelectionError({
|
|
||||||
message: `Variant unavailable for ${selected.providerID}/${selected.id}: ${requested?.variant}`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const runText = Effect.fn("Generate.text")(function* (input: TextInput) {
|
|
||||||
const selected = yield* selectModel(input.model)
|
|
||||||
const provider = yield* catalog.provider.get(selected.providerID)
|
|
||||||
const connection = yield* integrations.connection.active(
|
|
||||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
|
||||||
)
|
|
||||||
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
|
|
||||||
const model = yield* SessionRunnerModel.fromCatalogModel(selected, credential).pipe(
|
|
||||||
Effect.mapError((error) =>
|
|
||||||
input.model
|
|
||||||
? new ModelSelectionError({ message: error.message })
|
|
||||||
: new UnavailableError({ message: error.message, service: selected.providerID }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const response = yield* llm.generate(LLM.request({ model, prompt: input.prompt })).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(error: LLMError) =>
|
|
||||||
new UnavailableError({
|
|
||||||
message: error.message,
|
|
||||||
service: selected.providerID,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return response.text
|
|
||||||
})
|
|
||||||
|
|
||||||
const text: Interface["text"] = (input) =>
|
|
||||||
runText(input).pipe(
|
|
||||||
Effect.catchTag(
|
|
||||||
"Integration.Authorization",
|
|
||||||
() =>
|
|
||||||
new UnavailableError({
|
|
||||||
message: "Generation credentials are unavailable",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return Service.of({ text })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, Integration.node, llmClient] })
|
|
||||||
@@ -9,28 +9,23 @@ import { Node } from "./effect/app-node"
|
|||||||
import { FileMutation } from "./file-mutation"
|
import { FileMutation } from "./file-mutation"
|
||||||
import { FileSystem } from "./filesystem"
|
import { FileSystem } from "./filesystem"
|
||||||
import { FileSystemSearch } from "./filesystem/search"
|
import { FileSystemSearch } from "./filesystem/search"
|
||||||
import { Generate } from "./generate"
|
|
||||||
import { Watcher } from "./filesystem/watcher"
|
import { Watcher } from "./filesystem/watcher"
|
||||||
import { Image } from "./image"
|
import { Image } from "./image"
|
||||||
import { Integration } from "./integration"
|
import { Integration } from "./integration"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { LocationMutation } from "./location-mutation"
|
import { LocationMutation } from "./location-mutation"
|
||||||
import { LocationServiceMap } from "./location-service-map"
|
import { LocationServiceMap } from "./location-service-map"
|
||||||
import { MCP } from "./mcp/index"
|
|
||||||
import { PermissionV2 } from "./permission"
|
import { PermissionV2 } from "./permission"
|
||||||
import { PluginV2 } from "./plugin"
|
import { PluginV2 } from "./plugin"
|
||||||
import { PluginInternal } from "./plugin/internal"
|
import { PluginInternal } from "./plugin/internal"
|
||||||
import { Policy } from "./policy"
|
import { Policy } from "./policy"
|
||||||
import { Project } from "./project"
|
|
||||||
import { ProjectCopy } from "./project/copy"
|
import { ProjectCopy } from "./project/copy"
|
||||||
import { Pty } from "./pty"
|
import { Pty } from "./pty"
|
||||||
import { QuestionV2 } from "./question"
|
import { QuestionV2 } from "./question"
|
||||||
import { Shell } from "./shell"
|
|
||||||
import { Reference } from "./reference"
|
import { Reference } from "./reference"
|
||||||
import { ReferenceGuidance } from "./reference/guidance"
|
import { ReferenceGuidance } from "./reference/guidance"
|
||||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||||
import { SessionRunnerModel } from "./session/runner/model"
|
import { SessionRunnerModel } from "./session/runner/model"
|
||||||
import { SessionCompaction } from "./session/compaction"
|
|
||||||
import { SessionTodo } from "./session/todo"
|
import { SessionTodo } from "./session/todo"
|
||||||
import { SkillV2 } from "./skill"
|
import { SkillV2 } from "./skill"
|
||||||
import { SkillGuidance } from "./skill/guidance"
|
import { SkillGuidance } from "./skill/guidance"
|
||||||
@@ -38,7 +33,6 @@ import { Snapshot } from "./snapshot"
|
|||||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||||
import { SystemContextRegistry } from "./system-context/registry"
|
import { SystemContextRegistry } from "./system-context/registry"
|
||||||
import { BuiltInTools } from "./tool/builtins"
|
import { BuiltInTools } from "./tool/builtins"
|
||||||
import { McpTool } from "./tool/mcp"
|
|
||||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||||
import { ToolRegistry } from "./tool/registry"
|
import { ToolRegistry } from "./tool/registry"
|
||||||
import { ToolOutputStore } from "./tool-output-store"
|
import { ToolOutputStore } from "./tool-output-store"
|
||||||
@@ -46,7 +40,6 @@ import { ToolOutputStore } from "./tool-output-store"
|
|||||||
export { LocationServiceMap } from "./location-service-map"
|
export { LocationServiceMap } from "./location-service-map"
|
||||||
|
|
||||||
export const locationServices = LayerNode.group([
|
export const locationServices = LayerNode.group([
|
||||||
Project.node,
|
|
||||||
Location.node,
|
Location.node,
|
||||||
Policy.node,
|
Policy.node,
|
||||||
Config.node,
|
Config.node,
|
||||||
@@ -64,13 +57,11 @@ export const locationServices = LayerNode.group([
|
|||||||
FileSystem.node,
|
FileSystem.node,
|
||||||
Watcher.node,
|
Watcher.node,
|
||||||
Pty.node,
|
Pty.node,
|
||||||
Shell.node,
|
|
||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
SystemContextRegistry.node,
|
SystemContextRegistry.node,
|
||||||
SystemContextBuiltIns.node,
|
SystemContextBuiltIns.node,
|
||||||
LocationMutation.node,
|
LocationMutation.node,
|
||||||
FileMutation.node,
|
FileMutation.node,
|
||||||
MCP.node,
|
|
||||||
PermissionV2.node,
|
PermissionV2.node,
|
||||||
ToolOutputStore.node,
|
ToolOutputStore.node,
|
||||||
ToolRegistry.node,
|
ToolRegistry.node,
|
||||||
@@ -80,12 +71,9 @@ export const locationServices = LayerNode.group([
|
|||||||
ReferenceGuidance.node,
|
ReferenceGuidance.node,
|
||||||
SessionTodo.node,
|
SessionTodo.node,
|
||||||
QuestionV2.node,
|
QuestionV2.node,
|
||||||
Generate.node,
|
|
||||||
ReadToolFileSystem.node,
|
ReadToolFileSystem.node,
|
||||||
BuiltInTools.node,
|
BuiltInTools.node,
|
||||||
McpTool.node,
|
|
||||||
SessionRunnerModel.node,
|
SessionRunnerModel.node,
|
||||||
SessionCompaction.node,
|
|
||||||
Snapshot.node,
|
Snapshot.node,
|
||||||
SessionRunnerLLM.node,
|
SessionRunnerLLM.node,
|
||||||
])
|
])
|
||||||
@@ -94,17 +82,16 @@ export type LocationServices = LayerNode.Output<typeof locationServices>
|
|||||||
export type LocationError = LayerNode.Error<typeof locationServices>
|
export type LocationError = LayerNode.Error<typeof locationServices>
|
||||||
|
|
||||||
export function buildLocationServiceMap(
|
export function buildLocationServiceMap(
|
||||||
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
|
replacements: LayerNode.Replacements = [],
|
||||||
): Layer.Layer<LocationServiceMap.Service> {
|
): Layer.Layer<LocationServiceMap.Service> {
|
||||||
return Layer.effect(
|
return Layer.effect(
|
||||||
LocationServiceMap.Service,
|
LocationServiceMap.Service,
|
||||||
LayerMap.make(
|
LayerMap.make(
|
||||||
(ref: Location.Ref) => {
|
(ref: Location.Ref) => {
|
||||||
const location = LayerNode.hoist(
|
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
|
||||||
LayerNode.bind(locationServices, Location.node, Location.boundNode(ref)),
|
const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
|
||||||
Node.tags.values.global,
|
|
||||||
)
|
return LayerNode.compile(location.node).pipe(
|
||||||
return LayerNode.compile(location.node, replacements).pipe(
|
|
||||||
Layer.fresh,
|
Layer.fresh,
|
||||||
Layer.tap(() =>
|
Layer.tap(() =>
|
||||||
Effect.logInfo("booting location services", {
|
Effect.logInfo("booting location services", {
|
||||||
@@ -112,7 +99,7 @@ export function buildLocationServiceMap(
|
|||||||
workspaceID: ref.workspaceID,
|
workspaceID: ref.workspaceID,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Layer.provide(LayerNode.compile(location.hoisted, replacements)),
|
Layer.provide(LayerNode.compile(location.hoisted)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ idleTimeToLive: "60 minutes" },
|
{ idleTimeToLive: "60 minutes" },
|
||||||
|
|||||||
@@ -1,289 +0,0 @@
|
|||||||
export * as MCPClient from "./client"
|
|
||||||
|
|
||||||
import path from "node:path"
|
|
||||||
import { execFile } from "node:child_process"
|
|
||||||
import { pathToFileURL } from "node:url"
|
|
||||||
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
|
|
||||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
|
||||||
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
|
||||||
import {
|
|
||||||
CallToolResultSchema,
|
|
||||||
ListRootsRequestSchema,
|
|
||||||
ListToolsResultSchema,
|
|
||||||
type LoggingMessageNotification,
|
|
||||||
LoggingMessageNotificationSchema,
|
|
||||||
ToolListChangedNotificationSchema,
|
|
||||||
ToolSchema,
|
|
||||||
} from "@modelcontextprotocol/sdk/types.js"
|
|
||||||
import { Cause, Effect, Exit, Schema } from "effect"
|
|
||||||
import { ConfigMCP } from "../config/mcp"
|
|
||||||
import { InstallationVersion } from "../installation/version"
|
|
||||||
|
|
||||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
|
||||||
const DEFAULT_REQUEST_TIMEOUT = 30_000
|
|
||||||
|
|
||||||
type Transport = StdioClientTransport | StreamableHTTPClientTransport
|
|
||||||
|
|
||||||
// Some servers advertise tool outputSchemas the SDK's strict validator can't resolve; this drops
|
|
||||||
// only that field so a single bad schema doesn't blank out the whole tool list.
|
|
||||||
const TolerantListToolsResult = ListToolsResultSchema.extend({
|
|
||||||
tools: ToolSchema.omit({ outputSchema: true }).array(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export class NeedsAuthError extends Schema.TaggedErrorClass<NeedsAuthError>()("MCP.NeedsAuthError", {
|
|
||||||
server: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class ConnectError extends Schema.TaggedErrorClass<ConnectError>()("MCP.ConnectError", {
|
|
||||||
server: Schema.String,
|
|
||||||
message: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export interface ToolDefinition {
|
|
||||||
readonly name: string
|
|
||||||
readonly description: string | undefined
|
|
||||||
readonly inputSchema: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CallToolContent =
|
|
||||||
| { readonly type: "text"; readonly text: string }
|
|
||||||
| { readonly type: "media"; readonly data: string; readonly mimeType: string }
|
|
||||||
|
|
||||||
export interface CallToolResult {
|
|
||||||
readonly isError: boolean
|
|
||||||
readonly structured: unknown
|
|
||||||
readonly content: ReadonlyArray<CallToolContent>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogMessage {
|
|
||||||
readonly level: LoggingMessageNotification["params"]["level"]
|
|
||||||
readonly logger?: LoggingMessageNotification["params"]["logger"]
|
|
||||||
readonly data: LoggingMessageNotification["params"]["data"]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Handle over a connected MCP server that keeps the SDK `Client` out of the rest of core. */
|
|
||||||
export interface Connection {
|
|
||||||
/** Server-supplied usage instructions from the initialize result, if any. */
|
|
||||||
readonly instructions: string | undefined
|
|
||||||
/** Lists the server's tools; returns [] when the server doesn't advertise tool support, fails on a transport error. */
|
|
||||||
readonly tools: () => Effect.Effect<ToolDefinition[], Error>
|
|
||||||
/** Invokes a tool on the server. Interruption aborts the in-flight request. */
|
|
||||||
readonly callTool: (input: {
|
|
||||||
readonly name: string
|
|
||||||
readonly args?: Record<string, unknown>
|
|
||||||
}) => Effect.Effect<CallToolResult, Error>
|
|
||||||
readonly onClose: (callback: () => void) => void
|
|
||||||
/** Registers a callback fired when the server emits an MCP logging notification. */
|
|
||||||
readonly onLog: (callback: (message: LogMessage) => void) => void
|
|
||||||
/** Registers a callback fired when the server announces its tool list changed; no-op if unsupported. */
|
|
||||||
readonly onToolsChanged: (callback: () => void) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */
|
|
||||||
export const connect = Effect.fnUntraced(function* (
|
|
||||||
server: string,
|
|
||||||
config: typeof ConfigMCP.Server.Type,
|
|
||||||
directory: string,
|
|
||||||
// Only consumed by the remote transport; stdio servers have no auth concept. A provider with no
|
|
||||||
// stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth.
|
|
||||||
authProvider?: OAuthClientProvider,
|
|
||||||
) {
|
|
||||||
const transport: Transport = yield* Effect.gen(function* () {
|
|
||||||
if (config.type === "local") {
|
|
||||||
const [command, ...args] = config.command
|
|
||||||
return new StdioClientTransport({
|
|
||||||
command,
|
|
||||||
args,
|
|
||||||
cwd: config.cwd ? path.resolve(directory, config.cwd) : directory,
|
|
||||||
stderr: "pipe",
|
|
||||||
env: {
|
|
||||||
...(process.env as Record<string, string>),
|
|
||||||
...(command === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
|
||||||
...config.environment,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (!URL.canParse(config.url)) return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
|
|
||||||
return new StreamableHTTPClientTransport(new URL(config.url), {
|
|
||||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
|
||||||
authProvider,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const client = new Client(
|
|
||||||
{ name: "opencode", version: InstallationVersion },
|
|
||||||
{
|
|
||||||
capabilities: {
|
|
||||||
// https://github.com/anomalyco/opencode/issues/2308
|
|
||||||
roots: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
client.setRequestHandler(ListRootsRequestSchema, () =>
|
|
||||||
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const exit = yield* Effect.tryPromise({
|
|
||||||
try: (signal) => client.connect(transport, { timeout: config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT, signal }),
|
|
||||||
catch: (error) => error,
|
|
||||||
}).pipe(Effect.exit)
|
|
||||||
if (Exit.isSuccess(exit)) {
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
cleanupStdioDescendants(transport).pipe(
|
|
||||||
Effect.andThen(Effect.promise(() => client.close())),
|
|
||||||
Effect.ignore,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const requestTimeout = config.timeout?.request ?? DEFAULT_REQUEST_TIMEOUT
|
|
||||||
return {
|
|
||||||
instructions: client.getInstructions()?.trim() || undefined,
|
|
||||||
tools: () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (!client.getServerCapabilities()?.tools) return []
|
|
||||||
const tools = yield* Effect.tryPromise({
|
|
||||||
try: () =>
|
|
||||||
paginate(
|
|
||||||
async (cursor) => {
|
|
||||||
const params = cursor === undefined ? undefined : { cursor }
|
|
||||||
try {
|
|
||||||
return await client.listTools(params, { timeout: requestTimeout })
|
|
||||||
} catch (error) {
|
|
||||||
if (!(error instanceof Error) || !isOutputSchemaError(error)) throw error
|
|
||||||
return client.request({ method: "tools/list", params }, TolerantListToolsResult, {
|
|
||||||
timeout: requestTimeout,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(result) => result.tools,
|
|
||||||
),
|
|
||||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
|
||||||
}).pipe(
|
|
||||||
Effect.tapError((error) => Effect.logWarning("failed to list MCP tools", { server, error: error.message })),
|
|
||||||
)
|
|
||||||
return tools.map((tool) => ({
|
|
||||||
name: tool.name,
|
|
||||||
description: tool.description,
|
|
||||||
inputSchema: tool.inputSchema,
|
|
||||||
}))
|
|
||||||
}),
|
|
||||||
callTool: (input) =>
|
|
||||||
Effect.tryPromise({
|
|
||||||
try: (signal) =>
|
|
||||||
client.callTool(
|
|
||||||
{ name: input.name, arguments: input.args ?? {} },
|
|
||||||
CallToolResultSchema,
|
|
||||||
// The SDK only sends a progress token when onprogress is present, which enables timeout resets.
|
|
||||||
{ signal, timeout: requestTimeout, resetTimeoutOnProgress: true, onprogress: () => {} },
|
|
||||||
),
|
|
||||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
|
||||||
}).pipe(
|
|
||||||
Effect.map((result) => ({
|
|
||||||
isError: result.isError === true,
|
|
||||||
structured: result.structuredContent,
|
|
||||||
content: result.content.flatMap((part): CallToolContent[] => {
|
|
||||||
if (part.type === "text") return [{ type: "text", text: part.text }]
|
|
||||||
if (part.type === "image" || part.type === "audio")
|
|
||||||
return [{ type: "media", data: part.data, mimeType: part.mimeType }]
|
|
||||||
if (part.type === "resource_link") return [{ type: "text", text: part.uri }]
|
|
||||||
if (part.type === "resource") {
|
|
||||||
const resource = part.resource
|
|
||||||
if ("text" in resource && typeof resource.text === "string")
|
|
||||||
return [{ type: "text", text: resource.text }]
|
|
||||||
if ("blob" in resource && typeof resource.blob === "string" && typeof resource.mimeType === "string")
|
|
||||||
return [{ type: "media", data: resource.blob, mimeType: resource.mimeType }]
|
|
||||||
return [{ type: "text", text: resource.uri }]
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
onClose: (callback) => {
|
|
||||||
client.onclose = callback
|
|
||||||
},
|
|
||||||
onLog: (callback) => {
|
|
||||||
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => callback(notification.params))
|
|
||||||
},
|
|
||||||
onToolsChanged: (callback) => {
|
|
||||||
if (!client.getServerCapabilities()?.tools?.listChanged) return
|
|
||||||
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => callback())
|
|
||||||
},
|
|
||||||
} satisfies Connection
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* cleanupStdioDescendants(transport).pipe(
|
|
||||||
Effect.andThen(Effect.promise(() => transport.close())),
|
|
||||||
Effect.ignore,
|
|
||||||
)
|
|
||||||
const error = Cause.squash(exit.cause)
|
|
||||||
if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server })
|
|
||||||
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
|
|
||||||
})
|
|
||||||
|
|
||||||
// SDK close stops the MCP process, but not child processes it spawned.
|
|
||||||
const cleanupStdioDescendants = (transport: Transport) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (!(transport instanceof StdioClientTransport)) return
|
|
||||||
const pid = transport.pid
|
|
||||||
if (typeof pid !== "number") return
|
|
||||||
yield* Effect.forEach(
|
|
||||||
yield* descendantPids(pid),
|
|
||||||
(pid) =>
|
|
||||||
Effect.try({
|
|
||||||
try: () => process.kill(pid, "SIGTERM"),
|
|
||||||
catch: () => undefined,
|
|
||||||
}).pipe(Effect.ignore),
|
|
||||||
{ discard: true },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const descendantPids = Effect.fnUntraced(function* (root: number) {
|
|
||||||
if (process.platform === "win32") return []
|
|
||||||
const result: number[] = []
|
|
||||||
const queue = [root]
|
|
||||||
for (let index = 0; index < queue.length; index++) {
|
|
||||||
const parent = queue[index]
|
|
||||||
if (parent === undefined) return result
|
|
||||||
const children = (yield* childPids(parent)).filter((pid) => !result.includes(pid))
|
|
||||||
result.push(...children)
|
|
||||||
queue.push(...children)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const childPids = (pid: number) =>
|
|
||||||
Effect.promise(
|
|
||||||
() =>
|
|
||||||
new Promise<number[]>((resolve) => {
|
|
||||||
execFile("pgrep", ["-P", String(pid)], { encoding: "utf8" }, (_error, stdout) => {
|
|
||||||
resolve(
|
|
||||||
stdout
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => Number.parseInt(line, 10))
|
|
||||||
.filter((pid) => Number.isInteger(pid)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
async function paginate<R extends { nextCursor?: string }, T>(
|
|
||||||
list: (cursor: string | undefined) => Promise<R>,
|
|
||||||
items: (result: R) => T[],
|
|
||||||
) {
|
|
||||||
const collected: T[] = []
|
|
||||||
const seen = new Set<string>()
|
|
||||||
let cursor: string | undefined
|
|
||||||
while (true) {
|
|
||||||
const result = await list(cursor)
|
|
||||||
collected.push(...items(result))
|
|
||||||
if (result.nextCursor === undefined) return collected
|
|
||||||
// A repeating cursor never terminates; bail instead of hanging the connection forever.
|
|
||||||
if (seen.has(result.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${result.nextCursor}`)
|
|
||||||
seen.add(result.nextCursor)
|
|
||||||
cursor = result.nextCursor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOutputSchemaError = (error: Error) =>
|
|
||||||
/can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test(
|
|
||||||
error.message,
|
|
||||||
)
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
export * as McpGuidance from "./guidance"
|
|
||||||
|
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
|
||||||
import { AgentV2 } from "../agent"
|
|
||||||
import { PermissionV2 } from "../permission"
|
|
||||||
import { McpTool } from "../tool/mcp"
|
|
||||||
import { MCP } from "./index"
|
|
||||||
import { SystemContext } from "../system-context/index"
|
|
||||||
|
|
||||||
const Summary = Schema.Struct({
|
|
||||||
server: Schema.String,
|
|
||||||
instructions: Schema.String,
|
|
||||||
})
|
|
||||||
type Summary = typeof Summary.Type
|
|
||||||
|
|
||||||
const render = (servers: ReadonlyArray<Summary>) =>
|
|
||||||
[
|
|
||||||
"<mcp_instructions>",
|
|
||||||
...servers.flatMap((server) => [
|
|
||||||
` <server name="${server.server}">`,
|
|
||||||
...server.instructions.split("\n").map((line) => ` ${line}`),
|
|
||||||
" </server>",
|
|
||||||
]),
|
|
||||||
"</mcp_instructions>",
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/McpGuidance") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const mcp = yield* MCP.Service
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
load: Effect.fn("McpGuidance.load")(function* (selection) {
|
|
||||||
const agent = selection.info
|
|
||||||
if (!agent) return SystemContext.empty
|
|
||||||
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
|
|
||||||
concurrency: "unbounded",
|
|
||||||
})
|
|
||||||
// Hide a server only when every tool it contributes is wholly denied for this agent.
|
|
||||||
const visible = instructions
|
|
||||||
.filter((item) => {
|
|
||||||
const owned = tools.filter((tool) => tool.server === item.server)
|
|
||||||
return (
|
|
||||||
owned.length === 0 ||
|
|
||||||
owned.some(
|
|
||||||
(tool) => PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.map((item) => ({ server: item.server, instructions: item.instructions }))
|
|
||||||
if (visible.length === 0) return SystemContext.empty
|
|
||||||
return SystemContext.make({
|
|
||||||
key: SystemContext.Key.make("core/mcp-guidance"),
|
|
||||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
|
||||||
load: Effect.succeed(visible),
|
|
||||||
baseline: render,
|
|
||||||
update: (_previous, current) =>
|
|
||||||
[
|
|
||||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
|
||||||
render(current),
|
|
||||||
].join("\n"),
|
|
||||||
removed: () => "MCP server instructions are no longer available.",
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const locationLayer = layer
|
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] })
|
|
||||||
@@ -1,515 +0,0 @@
|
|||||||
export * as MCP from "./index"
|
|
||||||
|
|
||||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
|
||||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
|
||||||
import { createHash } from "node:crypto"
|
|
||||||
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect"
|
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
|
||||||
import { Config } from "../config"
|
|
||||||
import { ConfigMCP } from "../config/mcp"
|
|
||||||
import { Credential } from "../credential"
|
|
||||||
import { EventV2 } from "../event"
|
|
||||||
import { Integration } from "../integration"
|
|
||||||
import { IntegrationConnection } from "../integration/connection"
|
|
||||||
import { Location } from "../location"
|
|
||||||
import { MCPClient } from "./client"
|
|
||||||
import { MCPOAuth } from "./oauth"
|
|
||||||
|
|
||||||
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
|
|
||||||
export type ServerName = typeof ServerName.Type
|
|
||||||
|
|
||||||
// The status union is a public wire contract, so it lives in @opencode-ai/schema and is re-exported here.
|
|
||||||
export const Status = Mcp.Status
|
|
||||||
export type Status = Mcp.Status
|
|
||||||
|
|
||||||
export class ServerInfo extends Schema.Class<ServerInfo>("MCP.ServerInfo")({
|
|
||||||
name: ServerName,
|
|
||||||
status: Status,
|
|
||||||
integrationID: Integration.ID.pipe(Schema.optional),
|
|
||||||
connection: IntegrationConnection.Info.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class ServerInstructions extends Schema.Class<ServerInstructions>("MCP.ServerInstructions")({
|
|
||||||
server: ServerName,
|
|
||||||
instructions: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class Tool extends Schema.Class<Tool>("MCP.Tool")({
|
|
||||||
server: ServerName,
|
|
||||||
name: Schema.String,
|
|
||||||
description: Schema.String.pipe(Schema.optional),
|
|
||||||
inputSchema: Schema.Unknown.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export const ToolResultContent = Schema.Union([
|
|
||||||
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
|
|
||||||
Schema.Struct({ type: Schema.Literal("media"), data: Schema.String, mimeType: Schema.String }),
|
|
||||||
]).pipe(Schema.toTaggedUnion("type"))
|
|
||||||
export type ToolResultContent = typeof ToolResultContent.Type
|
|
||||||
|
|
||||||
export class ToolResult extends Schema.Class<ToolResult>("MCP.ToolResult")({
|
|
||||||
server: ServerName,
|
|
||||||
tool: Schema.String,
|
|
||||||
isError: Schema.Boolean,
|
|
||||||
structured: Schema.Unknown.pipe(Schema.optional),
|
|
||||||
content: Schema.Array(ToolResultContent),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class PromptArgument extends Schema.Class<PromptArgument>("MCP.PromptArgument")({
|
|
||||||
name: Schema.String,
|
|
||||||
description: Schema.String.pipe(Schema.optional),
|
|
||||||
required: Schema.Boolean.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class Prompt extends Schema.Class<Prompt>("MCP.Prompt")({
|
|
||||||
server: ServerName,
|
|
||||||
name: Schema.String,
|
|
||||||
description: Schema.String.pipe(Schema.optional),
|
|
||||||
arguments: Schema.Array(PromptArgument).pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class PromptMessage extends Schema.Class<PromptMessage>("MCP.PromptMessage")({
|
|
||||||
role: Schema.String,
|
|
||||||
content: Schema.Unknown,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class PromptResult extends Schema.Class<PromptResult>("MCP.PromptResult")({
|
|
||||||
server: ServerName,
|
|
||||||
name: Schema.String,
|
|
||||||
messages: Schema.Array(PromptMessage),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class Resource extends Schema.Class<Resource>("MCP.Resource")({
|
|
||||||
server: ServerName,
|
|
||||||
name: Schema.String,
|
|
||||||
uri: Schema.String,
|
|
||||||
description: Schema.String.pipe(Schema.optional),
|
|
||||||
mimeType: Schema.String.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class ResourceTemplate extends Schema.Class<ResourceTemplate>("MCP.ResourceTemplate")({
|
|
||||||
server: ServerName,
|
|
||||||
name: Schema.String,
|
|
||||||
uriTemplate: Schema.String,
|
|
||||||
description: Schema.String.pipe(Schema.optional),
|
|
||||||
mimeType: Schema.String.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class ResourceCatalog extends Schema.Class<ResourceCatalog>("MCP.ResourceCatalog")({
|
|
||||||
resources: Schema.Array(Resource),
|
|
||||||
templates: Schema.Array(ResourceTemplate),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export const ResourceContentPart = Schema.Union([
|
|
||||||
Schema.Struct({
|
|
||||||
type: Schema.Literal("text"),
|
|
||||||
uri: Schema.String,
|
|
||||||
text: Schema.String,
|
|
||||||
mimeType: Schema.String.pipe(Schema.optional),
|
|
||||||
}),
|
|
||||||
Schema.Struct({
|
|
||||||
type: Schema.Literal("blob"),
|
|
||||||
uri: Schema.String,
|
|
||||||
blob: Schema.String,
|
|
||||||
mimeType: Schema.String.pipe(Schema.optional),
|
|
||||||
}),
|
|
||||||
]).pipe(Schema.toTaggedUnion("type"))
|
|
||||||
export type ResourceContentPart = typeof ResourceContentPart.Type
|
|
||||||
|
|
||||||
export class ResourceContent extends Schema.Class<ResourceContent>("MCP.ResourceContent")({
|
|
||||||
server: ServerName,
|
|
||||||
uri: Schema.String,
|
|
||||||
contents: Schema.Array(ResourceContentPart),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
|
|
||||||
server: ServerName,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class ToolCallError extends Schema.TaggedErrorClass<ToolCallError>()("MCP.ToolCallError", {
|
|
||||||
server: ServerName,
|
|
||||||
tool: Schema.String,
|
|
||||||
message: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
type ServerEntry = {
|
|
||||||
readonly config: typeof ConfigMCP.Server.Type
|
|
||||||
status: Status
|
|
||||||
readonly startup: Deferred.Deferred<void>
|
|
||||||
scope?: Scope.Closeable
|
|
||||||
client?: MCPClient.Connection
|
|
||||||
tools?: ReadonlyArray<Tool>
|
|
||||||
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
|
|
||||||
integrationID?: Integration.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly servers: () => Effect.Effect<ServerInfo[]>
|
|
||||||
readonly tools: () => Effect.Effect<Tool[]>
|
|
||||||
readonly callTool: (input: {
|
|
||||||
readonly server: ServerName | string
|
|
||||||
readonly name: string
|
|
||||||
readonly args?: Record<string, unknown>
|
|
||||||
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
|
|
||||||
readonly instructions: () => Effect.Effect<ServerInstructions[]>
|
|
||||||
readonly prompts: () => Effect.Effect<Prompt[]>
|
|
||||||
readonly prompt: (input: {
|
|
||||||
readonly server: ServerName | string
|
|
||||||
readonly name: string
|
|
||||||
readonly args?: Record<string, string>
|
|
||||||
}) => Effect.Effect<PromptResult | undefined, NotFoundError>
|
|
||||||
readonly resourceCatalog: () => Effect.Effect<ResourceCatalog>
|
|
||||||
readonly readResource: (input: {
|
|
||||||
readonly server: ServerName | string
|
|
||||||
readonly uri: string
|
|
||||||
}) => Effect.Effect<ResourceContent | undefined, NotFoundError>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/MCP") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const config = yield* Config.Service
|
|
||||||
const location = yield* Location.Service
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const integration = yield* Integration.Service
|
|
||||||
const credentials = yield* Credential.Service
|
|
||||||
const root = yield* Scope.make()
|
|
||||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
|
||||||
yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
|
|
||||||
|
|
||||||
const documents = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
|
|
||||||
// Global MCP timeout defaults, later config files overriding earlier ones.
|
|
||||||
const timeout = Object.assign(
|
|
||||||
{},
|
|
||||||
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
|
|
||||||
)
|
|
||||||
// Later config files win for duplicate server names; per-server timeout overrides globals.
|
|
||||||
const runtime = new Map<ServerName, ServerEntry>()
|
|
||||||
for (const entry of documents) {
|
|
||||||
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
|
|
||||||
runtime.set(ServerName.make(name), {
|
|
||||||
config: { ...server, timeout: { ...timeout, ...server.timeout } },
|
|
||||||
status: { status: "disconnected" },
|
|
||||||
startup: Deferred.makeUnsafe<void>(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register every remote server as an OAuth integration so credentials live in the global store
|
|
||||||
// rather than in committed config. Servers that connect anonymously simply never use the method.
|
|
||||||
const registrations: Array<{
|
|
||||||
readonly name: ServerName
|
|
||||||
readonly remote: typeof ConfigMCP.Remote.Type
|
|
||||||
readonly integrationID: Integration.ID
|
|
||||||
readonly methodID: Integration.MethodID
|
|
||||||
}> = []
|
|
||||||
for (const [name, entry] of runtime) {
|
|
||||||
if (entry.config.type !== "remote" || entry.config.oauth === false) continue
|
|
||||||
const remote = entry.config
|
|
||||||
// Key identity on name + url, not url alone: two configs for the same url under different names are
|
|
||||||
// distinct logical servers that may hold different accounts, so they must not share a credential row.
|
|
||||||
const suffix =
|
|
||||||
"mcp_" +
|
|
||||||
createHash("sha1")
|
|
||||||
.update(name + "\u0000" + remote.url)
|
|
||||||
.digest("hex")
|
|
||||||
.slice(0, 16)
|
|
||||||
entry.integrationID = Integration.ID.make(suffix)
|
|
||||||
registrations.push({
|
|
||||||
name,
|
|
||||||
remote,
|
|
||||||
integrationID: entry.integrationID,
|
|
||||||
methodID: Integration.MethodID.make(suffix),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (registrations.length > 0)
|
|
||||||
yield* integration.transform((draft) => {
|
|
||||||
for (const reg of registrations) {
|
|
||||||
draft.update(reg.integrationID, (ref) => {
|
|
||||||
ref.name = reg.name
|
|
||||||
})
|
|
||||||
draft.method.update({
|
|
||||||
integrationID: reg.integrationID,
|
|
||||||
method: { id: reg.methodID, type: "oauth", label: reg.name },
|
|
||||||
authorize: () => MCPOAuth.authorize({ name: reg.name, config: reg.remote, methodID: reg.methodID }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const requireServer = Effect.fnUntraced(function* (server: ServerName | string) {
|
|
||||||
const name = ServerName.make(server)
|
|
||||||
const entry = runtime.get(name)
|
|
||||||
if (!entry) return yield* new NotFoundError({ server: name })
|
|
||||||
return { name, entry }
|
|
||||||
})
|
|
||||||
|
|
||||||
const info = (name: ServerName, entry: ServerEntry, connection: IntegrationConnection.Info | undefined) =>
|
|
||||||
new ServerInfo({
|
|
||||||
name,
|
|
||||||
status: entry.status,
|
|
||||||
integrationID: entry.integrationID,
|
|
||||||
connection,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Builds the connect-time auth provider for a remote OAuth-integration server. The SDK presents and
|
|
||||||
// refreshes stored tokens, persisting refreshes back to the same credential row. The provider never
|
|
||||||
// opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
|
|
||||||
const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) {
|
|
||||||
if (entry.config.type !== "remote" || !entry.integrationID) return undefined
|
|
||||||
const remote = entry.config
|
|
||||||
const oauth = remote.oauth || undefined
|
|
||||||
const base = {
|
|
||||||
redirectUrl: oauth?.redirect_uri ?? "http://127.0.0.1/callback",
|
|
||||||
scope: oauth?.scope,
|
|
||||||
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
|
|
||||||
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
|
|
||||||
onRedirect: () => {},
|
|
||||||
}
|
|
||||||
const stored = yield* credentials.list(entry.integrationID)
|
|
||||||
const found = stored.find((credential) => credential.value.type === "oauth")
|
|
||||||
if (!found || found.value.type !== "oauth")
|
|
||||||
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
|
|
||||||
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
|
|
||||||
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
|
|
||||||
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
|
|
||||||
return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
|
|
||||||
const credentialID = found.id
|
|
||||||
const methodID = found.value.methodID
|
|
||||||
let current: Credential.OAuth | undefined = found.value
|
|
||||||
return MCPOAuth.provider({
|
|
||||||
...base,
|
|
||||||
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth. Uses the raw
|
|
||||||
// credential service (no integration event) to avoid re-triggering the reconnect subscriber mid-connect.
|
|
||||||
invalidate: async (scope) => {
|
|
||||||
if (scope === "verifier" || scope === "discovery") return
|
|
||||||
current = undefined
|
|
||||||
await Effect.runPromise(credentials.remove(credentialID))
|
|
||||||
},
|
|
||||||
store: {
|
|
||||||
tokens: async () => (current ? MCPOAuth.toTokens(current) : undefined),
|
|
||||||
saveTokens: async (tokens) => {
|
|
||||||
current = MCPOAuth.toCredential({
|
|
||||||
methodID,
|
|
||||||
serverUrl: remote.url,
|
|
||||||
tokens,
|
|
||||||
client: current ? MCPOAuth.clientFromCredential(current) : undefined,
|
|
||||||
})
|
|
||||||
await Effect.runPromise(credentials.update(credentialID, { value: current }))
|
|
||||||
},
|
|
||||||
clientInformation: async () => (current ? MCPOAuth.clientFromCredential(current) : undefined),
|
|
||||||
saveClientInformation: async () => {},
|
|
||||||
codeVerifier: async () => undefined,
|
|
||||||
saveCodeVerifier: async () => {},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
|
|
||||||
new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema })
|
|
||||||
|
|
||||||
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
|
||||||
connection.tools().pipe(
|
|
||||||
Effect.map((defs) => {
|
|
||||||
entry.tools = defs.map((def) => toTool(name, def))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
|
||||||
connection.onClose(() => {
|
|
||||||
// A reconnect closes the previous scope, but the SDK may fire this onclose after the new
|
|
||||||
// connection is already assigned; ignore the stale close so it can't null out the live client.
|
|
||||||
if (entry.client !== connection) return
|
|
||||||
entry.client = undefined
|
|
||||||
entry.tools = undefined
|
|
||||||
entry.status = { status: "failed", error: "Connection closed" }
|
|
||||||
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
|
|
||||||
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
|
|
||||||
})
|
|
||||||
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
|
|
||||||
connection.onToolsChanged(() => {
|
|
||||||
fork(
|
|
||||||
refreshTools(name, entry, connection).pipe(
|
|
||||||
Effect.andThen(events.publish(McpEvent.ToolsChanged, { server: name })),
|
|
||||||
Effect.ignore,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
|
||||||
const fields = { server, logger: message.logger, level: message.level, data: message.data }
|
|
||||||
switch (message.level) {
|
|
||||||
case "debug":
|
|
||||||
return Effect.logDebug("MCP server log", fields)
|
|
||||||
case "info":
|
|
||||||
case "notice":
|
|
||||||
return Effect.logInfo("MCP server log", fields)
|
|
||||||
case "warning":
|
|
||||||
return Effect.logWarning("MCP server log", fields)
|
|
||||||
case "error":
|
|
||||||
case "critical":
|
|
||||||
case "alert":
|
|
||||||
case "emergency":
|
|
||||||
return Effect.logError("MCP server log", fields)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const startServer = (name: ServerName, entry: ServerEntry) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const scope = yield* Scope.fork(root)
|
|
||||||
entry.scope = scope
|
|
||||||
const authProvider = yield* connectProvider(entry)
|
|
||||||
// List tools as part of connect so a failure here marks the server failed rather than
|
|
||||||
// leaving it connected with a silently empty tool list and no path to recover.
|
|
||||||
const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider).pipe(
|
|
||||||
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((defs) => ({ connection, defs })))),
|
|
||||||
Scope.provide(scope),
|
|
||||||
Effect.exit,
|
|
||||||
)
|
|
||||||
if (Exit.isSuccess(result)) {
|
|
||||||
entry.client = result.value.connection
|
|
||||||
entry.tools = result.value.defs.map((def) => toTool(name, def))
|
|
||||||
entry.status = { status: "connected" }
|
|
||||||
watch(name, entry, result.value.connection)
|
|
||||||
yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length })
|
|
||||||
// Announce the new tool set so the tool registry registers it. A server that finishes connecting
|
|
||||||
// after the initial registration sweep and emits no list-changed notification would otherwise
|
|
||||||
// stay invisible to the model.
|
|
||||||
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
|
||||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
yield* Scope.close(scope, Exit.void)
|
|
||||||
entry.scope = undefined
|
|
||||||
const error = Cause.squash(result.cause)
|
|
||||||
entry.status =
|
|
||||||
error instanceof MCPClient.NeedsAuthError
|
|
||||||
? { status: "needs_auth" }
|
|
||||||
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
|
|
||||||
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
|
|
||||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
|
||||||
}).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined)))
|
|
||||||
|
|
||||||
// Disabled servers settle their startup immediately so queries never block on them.
|
|
||||||
for (const [name, entry] of runtime) {
|
|
||||||
if (entry.config.disabled) {
|
|
||||||
entry.status = { status: "disabled" }
|
|
||||||
Deferred.doneUnsafe(entry.startup, Exit.void)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fork(startServer(name, entry))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bring a server online (or back to needs_auth) when its integration's credential changes, so an
|
|
||||||
// OAuth login takes effect without a restart. Only fires for the integrations we registered.
|
|
||||||
const owned = new Set(registrations.map((reg) => reg.integrationID))
|
|
||||||
const reconnect = (integrationID: Integration.ID) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID)
|
|
||||||
if (!match) return
|
|
||||||
const [name, entry] = match
|
|
||||||
if (entry.config.disabled) return
|
|
||||||
if (entry.scope) {
|
|
||||||
yield* Scope.close(entry.scope, Exit.void)
|
|
||||||
entry.scope = undefined
|
|
||||||
entry.client = undefined
|
|
||||||
entry.tools = undefined
|
|
||||||
}
|
|
||||||
yield* startServer(name, entry)
|
|
||||||
})
|
|
||||||
fork(
|
|
||||||
events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
|
||||||
Stream.filter((event) => owned.has(event.data.integrationID)),
|
|
||||||
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
|
|
||||||
Effect.ignore,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const whenAllReady = Effect.forEach(runtime.values(), (entry) => Deferred.await(entry.startup), {
|
|
||||||
concurrency: "unbounded",
|
|
||||||
discard: true,
|
|
||||||
})
|
|
||||||
const gate = Effect.fnUntraced(function* (server: ServerName | string) {
|
|
||||||
const target = yield* requireServer(server)
|
|
||||||
yield* Deferred.await(target.entry.startup)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
servers: Effect.fn("MCP.servers")(function* () {
|
|
||||||
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
|
|
||||||
return yield* Effect.forEach(entries, ([name, entry]) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const connection = entry.integrationID
|
|
||||||
? yield* integration.connection.active(entry.integrationID)
|
|
||||||
: undefined
|
|
||||||
return info(name, entry, connection)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
tools: Effect.fn("MCP.tools")(function* () {
|
|
||||||
yield* whenAllReady
|
|
||||||
return Array.from(runtime.values())
|
|
||||||
.flatMap((entry) => entry.tools ?? [])
|
|
||||||
.toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
|
|
||||||
}),
|
|
||||||
callTool: Effect.fn("MCP.callTool")(function* (input) {
|
|
||||||
const target = yield* requireServer(input.server)
|
|
||||||
yield* Deferred.await(target.entry.startup)
|
|
||||||
if (!target.entry.client)
|
|
||||||
return yield* new ToolCallError({
|
|
||||||
server: target.name,
|
|
||||||
tool: input.name,
|
|
||||||
message: "MCP server is not connected",
|
|
||||||
})
|
|
||||||
const result = yield* target.entry.client
|
|
||||||
.callTool({ name: input.name, args: input.args })
|
|
||||||
.pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return new ToolResult({
|
|
||||||
server: target.name,
|
|
||||||
tool: input.name,
|
|
||||||
isError: result.isError,
|
|
||||||
structured: result.structured,
|
|
||||||
content: result.content,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
instructions: Effect.fn("MCP.instructions")(function* () {
|
|
||||||
yield* whenAllReady
|
|
||||||
return Array.from(runtime)
|
|
||||||
.flatMap(([server, entry]) => {
|
|
||||||
const instructions = entry.client?.instructions
|
|
||||||
if (!instructions) return []
|
|
||||||
return [new ServerInstructions({ server, instructions })]
|
|
||||||
})
|
|
||||||
.toSorted((a, b) => a.server.localeCompare(b.server))
|
|
||||||
}),
|
|
||||||
prompts: Effect.fn("MCP.prompts")(function* () {
|
|
||||||
yield* whenAllReady
|
|
||||||
return []
|
|
||||||
}),
|
|
||||||
prompt: Effect.fn("MCP.prompt")(function* (input) {
|
|
||||||
yield* gate(input.server)
|
|
||||||
return undefined
|
|
||||||
}),
|
|
||||||
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
|
|
||||||
yield* whenAllReady
|
|
||||||
return new ResourceCatalog({ resources: [], templates: [] })
|
|
||||||
}),
|
|
||||||
readResource: Effect.fn("MCP.readResource")(function* (input) {
|
|
||||||
yield* gate(input.server)
|
|
||||||
return undefined
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const node = makeLocationNode({
|
|
||||||
service: Service,
|
|
||||||
layer,
|
|
||||||
deps: [Config.node, Location.node, EventV2.node, Integration.node, Credential.node],
|
|
||||||
})
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
export * as MCPOAuth from "./oauth"
|
|
||||||
|
|
||||||
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
|
||||||
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
|
|
||||||
import { createServer } from "node:http"
|
|
||||||
import { Deferred, Effect } from "effect"
|
|
||||||
import { Credential } from "@opencode-ai/schema/credential"
|
|
||||||
import { ConfigMCP } from "../config/mcp"
|
|
||||||
import { OauthCallbackPage } from "../oauth/page"
|
|
||||||
import type { Integration } from "../integration"
|
|
||||||
|
|
||||||
/** Persists the OAuth artifacts for one MCP server session: DCR client info, PKCE verifier, and tokens. */
|
|
||||||
export interface Store {
|
|
||||||
readonly tokens: () => Promise<OAuthTokens | undefined>
|
|
||||||
readonly saveTokens: (tokens: OAuthTokens) => Promise<void>
|
|
||||||
readonly clientInformation: () => Promise<OAuthClientInformationMixed | undefined>
|
|
||||||
readonly saveClientInformation: (info: OAuthClientInformationMixed) => Promise<void>
|
|
||||||
readonly codeVerifier: () => Promise<string | undefined>
|
|
||||||
readonly saveCodeVerifier: (verifier: string) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Options {
|
|
||||||
/** Loopback URL the authorization server redirects back to after the user approves. */
|
|
||||||
readonly redirectUrl: string
|
|
||||||
/** Space-delimited OAuth scopes to request when the server requires specific ones. */
|
|
||||||
readonly scope?: string
|
|
||||||
/** CSRF state embedded in the authorization request; required by the spec and enforced by some servers.
|
|
||||||
* The caller is responsible for validating the value echoed back to the redirect. */
|
|
||||||
readonly state?: string
|
|
||||||
/** Statically pre-registered client credentials from config; when set, the SDK skips dynamic registration. */
|
|
||||||
readonly client?: { readonly id: string; readonly secret?: string }
|
|
||||||
/** Invoked by the SDK to drop credentials it has determined are invalid (e.g. a rejected refresh token). */
|
|
||||||
readonly invalidate?: (scope: "all" | "client" | "tokens" | "verifier" | "discovery") => void | Promise<void>
|
|
||||||
/** Receives the authorization URL so the caller can open a browser and capture the eventual code. */
|
|
||||||
readonly onRedirect: (url: URL) => void | Promise<void>
|
|
||||||
readonly store: Store
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the MCP SDK's OAuthClientProvider. The SDK drives dynamic client registration, PKCE, and
|
|
||||||
* token refresh through these callbacks; we only persist whatever it hands back via `store`.
|
|
||||||
*/
|
|
||||||
export const provider = (options: Options): OAuthClientProvider => {
|
|
||||||
const state = options.state
|
|
||||||
const client = options.client
|
|
||||||
return {
|
|
||||||
redirectUrl: options.redirectUrl,
|
|
||||||
clientMetadata: {
|
|
||||||
redirect_uris: [options.redirectUrl],
|
|
||||||
client_name: "opencode",
|
|
||||||
client_uri: "https://opencode.ai",
|
|
||||||
grant_types: ["authorization_code", "refresh_token"],
|
|
||||||
response_types: ["code"],
|
|
||||||
token_endpoint_auth_method: client?.secret ? "client_secret_post" : "none",
|
|
||||||
...(options.scope ? { scope: options.scope } : {}),
|
|
||||||
},
|
|
||||||
// Only advertise state when the caller supplied one (the interactive flow); the connect-time
|
|
||||||
// provider has no redirect to validate, so it omits it.
|
|
||||||
...(state !== undefined ? { state: () => state } : {}),
|
|
||||||
// Static client config short-circuits dynamic registration; otherwise the SDK registers and we persist.
|
|
||||||
clientInformation: () =>
|
|
||||||
client ? { client_id: client.id, client_secret: client.secret } : options.store.clientInformation(),
|
|
||||||
saveClientInformation: (info) => options.store.saveClientInformation(info),
|
|
||||||
tokens: () => options.store.tokens(),
|
|
||||||
saveTokens: (tokens) => options.store.saveTokens(tokens),
|
|
||||||
redirectToAuthorization: (url) => options.onRedirect(url),
|
|
||||||
...(options.invalidate ? { invalidateCredentials: options.invalidate } : {}),
|
|
||||||
saveCodeVerifier: (verifier) => options.store.saveCodeVerifier(verifier),
|
|
||||||
// The SDK only reads the verifier back after saving one earlier in the same flow; a miss means
|
|
||||||
// the flow was resumed without its session state, which the SDK surfaces as an auth failure.
|
|
||||||
codeVerifier: async () => {
|
|
||||||
const verifier = await options.store.codeVerifier()
|
|
||||||
if (!verifier) throw new Error("Missing PKCE code verifier for MCP OAuth flow")
|
|
||||||
return verifier
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A Store that keeps OAuth artifacts in memory for the duration of one interactive login attempt. */
|
|
||||||
export const memoryStore = (): Store => {
|
|
||||||
let tokens: OAuthTokens | undefined
|
|
||||||
let client: OAuthClientInformationMixed | undefined
|
|
||||||
let verifier: string | undefined
|
|
||||||
return {
|
|
||||||
tokens: async () => tokens,
|
|
||||||
saveTokens: async (value) => {
|
|
||||||
tokens = value
|
|
||||||
},
|
|
||||||
clientInformation: async () => client,
|
|
||||||
saveClientInformation: async (value) => {
|
|
||||||
client = value
|
|
||||||
},
|
|
||||||
codeVerifier: async () => verifier,
|
|
||||||
saveCodeVerifier: async (value) => {
|
|
||||||
verifier = value
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads the dynamically-registered client info we stash in a credential's metadata, for token refresh. */
|
|
||||||
export const clientFromCredential = (credential: Credential.OAuth) =>
|
|
||||||
credential.metadata?.client as OAuthClientInformationMixed | undefined
|
|
||||||
|
|
||||||
/** Folds SDK tokens (plus DCR client info and the server URL) into a storable credential. */
|
|
||||||
export const toCredential = (input: {
|
|
||||||
readonly methodID: Integration.MethodID
|
|
||||||
readonly serverUrl: string
|
|
||||||
readonly tokens: OAuthTokens
|
|
||||||
readonly client: OAuthClientInformationMixed | undefined
|
|
||||||
}) =>
|
|
||||||
Credential.OAuth.make({
|
|
||||||
type: "oauth",
|
|
||||||
methodID: input.methodID,
|
|
||||||
access: input.tokens.access_token,
|
|
||||||
refresh: input.tokens.refresh_token ?? "",
|
|
||||||
// 0 marks an unknown/non-expiring token; toTokens then omits expires_in so the SDK won't force a refresh.
|
|
||||||
expires: input.tokens.expires_in ? Date.now() + input.tokens.expires_in * 1000 : 0,
|
|
||||||
metadata: {
|
|
||||||
serverUrl: input.serverUrl,
|
|
||||||
tokenType: input.tokens.token_type,
|
|
||||||
...(input.tokens.scope ? { scope: input.tokens.scope } : {}),
|
|
||||||
...(input.client ? { client: input.client } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Reconstructs SDK tokens from a stored credential so the connect-time provider can present them. */
|
|
||||||
export const toTokens = (credential: Credential.OAuth): OAuthTokens => {
|
|
||||||
const metadata = credential.metadata ?? {}
|
|
||||||
return {
|
|
||||||
access_token: credential.access,
|
|
||||||
token_type: typeof metadata.tokenType === "string" ? metadata.tokenType : "Bearer",
|
|
||||||
...(credential.refresh ? { refresh_token: credential.refresh } : {}),
|
|
||||||
...(credential.expires ? { expires_in: Math.max(0, Math.floor((credential.expires - Date.now()) / 1000)) } : {}),
|
|
||||||
...(typeof metadata.scope === "string" ? { scope: metadata.scope } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs the interactive OAuth login for one remote MCP server. Stands up a loopback callback server,
|
|
||||||
* lets the SDK drive DCR + PKCE to produce an authorization URL, and returns an attempt whose callback
|
|
||||||
* exchanges the redirect code for a storable credential. Scoped: the callback server closes with the scope.
|
|
||||||
*/
|
|
||||||
export const authorize = (input: {
|
|
||||||
readonly name: string
|
|
||||||
readonly config: typeof ConfigMCP.Remote.Type
|
|
||||||
readonly methodID: Integration.MethodID
|
|
||||||
}) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const oauth = input.config.oauth || undefined
|
|
||||||
const store = memoryStore()
|
|
||||||
const code = yield* Deferred.make<string, Error>()
|
|
||||||
const redirectPath = oauth?.redirect_uri ? new URL(oauth.redirect_uri).pathname : "/callback"
|
|
||||||
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
|
|
||||||
|
|
||||||
const server = createServer((request, response) => {
|
|
||||||
const url = new URL(request.url ?? "/", "http://127.0.0.1")
|
|
||||||
if (url.pathname !== redirectPath) {
|
|
||||||
response.writeHead(404).end("Not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const fail = (reason: string) => {
|
|
||||||
Effect.runFork(Deferred.fail(code, new Error(reason)))
|
|
||||||
response.writeHead(400, { "Content-Type": "text/html" }).end(OauthCallbackPage.error(reason, { provider: input.name }))
|
|
||||||
}
|
|
||||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
|
||||||
if (error) return fail(error)
|
|
||||||
// Reject a redirect whose state does not match what we issued: this is the CSRF defense the
|
|
||||||
// state parameter exists for, so an attacker can't inject their own authorization code.
|
|
||||||
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch")
|
|
||||||
const value = url.searchParams.get("code")
|
|
||||||
if (!value) return fail("Missing authorization code")
|
|
||||||
Effect.runFork(Deferred.succeed(code, value))
|
|
||||||
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name }))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Bind the port the redirect will actually arrive on: an explicit callback_port wins, else the port
|
|
||||||
// pinned by redirect_uri, else an ephemeral port. Binding ephemerally while redirect_uri names a fixed
|
|
||||||
// port would send the browser somewhere nothing is listening, hanging the attempt until it expires.
|
|
||||||
const redirectPort = oauth?.redirect_uri ? Number(new URL(oauth.redirect_uri).port) || undefined : undefined
|
|
||||||
const port = yield* Effect.callback<number, Error>((resume) => {
|
|
||||||
server.once("error", (error) => resume(Effect.fail(error)))
|
|
||||||
server.listen(oauth?.callback_port ?? redirectPort ?? 0, "127.0.0.1", () => {
|
|
||||||
const address = server.address()
|
|
||||||
resume(
|
|
||||||
address && typeof address === "object"
|
|
||||||
? Effect.succeed(address.port)
|
|
||||||
: Effect.fail(new Error("Could not determine MCP OAuth callback port")),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
|
||||||
|
|
||||||
let authorizationUrl: URL | undefined
|
|
||||||
const oauthProvider = provider({
|
|
||||||
redirectUrl: oauth?.redirect_uri ?? `http://127.0.0.1:${port}${redirectPath}`,
|
|
||||||
scope: oauth?.scope,
|
|
||||||
state,
|
|
||||||
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
|
|
||||||
onRedirect: (url) => {
|
|
||||||
authorizationUrl = url
|
|
||||||
},
|
|
||||||
store,
|
|
||||||
})
|
|
||||||
|
|
||||||
const finalize = Effect.gen(function* () {
|
|
||||||
const tokens = yield* Effect.promise(() => store.tokens())
|
|
||||||
if (!tokens) return yield* Effect.fail(new Error(`MCP server "${input.name}" did not return OAuth tokens`))
|
|
||||||
const client = yield* Effect.promise(() => store.clientInformation())
|
|
||||||
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = yield* Effect.tryPromise({
|
|
||||||
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }),
|
|
||||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
|
||||||
})
|
|
||||||
|
|
||||||
// The provider may already hold valid tokens (e.g. a re-auth), in which case there is no browser step.
|
|
||||||
if (result === "AUTHORIZED") {
|
|
||||||
return { url: input.config.url, instructions: `Connected to ${input.name}.`, mode: "auto" as const, callback: finalize }
|
|
||||||
}
|
|
||||||
if (!authorizationUrl)
|
|
||||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`))
|
|
||||||
|
|
||||||
return {
|
|
||||||
url: authorizationUrl.toString(),
|
|
||||||
instructions: `Authorize ${input.name} in your browser. This window will close automatically.`,
|
|
||||||
mode: "auto" as const,
|
|
||||||
callback: Deferred.await(code).pipe(
|
|
||||||
Effect.flatMap((value) =>
|
|
||||||
Effect.tryPromise({
|
|
||||||
try: () => auth(oauthProvider, { serverUrl: input.config.url, authorizationCode: value, scope: oauth?.scope }),
|
|
||||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Effect.flatMap(() => finalize),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -44,21 +44,6 @@ const Cost = Schema.Struct({
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
const ReasoningOption = Schema.Union([
|
|
||||||
Schema.Struct({
|
|
||||||
type: Schema.Literal("effort"),
|
|
||||||
values: Schema.Array(Schema.String),
|
|
||||||
}),
|
|
||||||
Schema.Struct({
|
|
||||||
type: Schema.Literal("toggle"),
|
|
||||||
}),
|
|
||||||
Schema.Struct({
|
|
||||||
type: Schema.Literal("budget_tokens"),
|
|
||||||
min: Schema.optional(Schema.Finite),
|
|
||||||
max: Schema.optional(Schema.Finite),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
export const Model = Schema.Struct({
|
export const Model = Schema.Struct({
|
||||||
id: Schema.String,
|
id: Schema.String,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
@@ -66,7 +51,6 @@ export const Model = Schema.Struct({
|
|||||||
release_date: Schema.String,
|
release_date: Schema.String,
|
||||||
attachment: Schema.Boolean,
|
attachment: Schema.Boolean,
|
||||||
reasoning: Schema.Boolean,
|
reasoning: Schema.Boolean,
|
||||||
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
|
|
||||||
temperature: Schema.Boolean,
|
temperature: Schema.Boolean,
|
||||||
tool_call: Schema.Boolean,
|
tool_call: Schema.Boolean,
|
||||||
interleaved: Schema.optional(
|
interleaved: Schema.optional(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * as Observability from "./observability"
|
export * as Observability from "./observability"
|
||||||
|
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
|
import { LayerNode } from "./effect/layer-node"
|
||||||
import { Effect, Layer, Logger, References } from "effect"
|
import { Effect, Layer, Logger, References } from "effect"
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import { OtlpSerialization } from "effect/unstable/observability"
|
import { OtlpSerialization } from "effect/unstable/observability"
|
||||||
@@ -19,3 +20,5 @@ export const layer = Layer.unwrap(
|
|||||||
return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer))
|
return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = LayerNode.make({ name: "observability", layer, deps: [] })
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Formatter, Logger, type LogLevel } from "effect"
|
import { Formatter, Logger, type LogLevel } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { InstallationChannel, InstallationLocal } from "../installation/version"
|
|
||||||
import { runID } from "./shared"
|
import { runID } from "./shared"
|
||||||
|
|
||||||
function formatter(id: string = runID) {
|
function formatter(id: string = runID) {
|
||||||
@@ -47,14 +46,9 @@ function format(input: unknown) {
|
|||||||
return /^[^\s="\\]+$/.test(value) ? value : JSON.stringify(value)
|
return /^[^\s="\\]+$/.test(value) ? value : JSON.stringify(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function file(local = InstallationLocal, channel = InstallationChannel) {
|
export function fileLogger(file = path.join(Global.Path.log, "opencode.log"), id: string = runID) {
|
||||||
if (!local) return path.join(Global.Path.log, "opencode.log")
|
|
||||||
return path.join(Global.Path.log, `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.log`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fileLogger(target = file(), id: string = runID) {
|
|
||||||
// Do not set batchWindow to 0; it causes high idle CPU usage.
|
// Do not set batchWindow to 0; it causes high idle CPU usage.
|
||||||
return Logger.toFile(formatter(id), target, { flag: "a" })
|
return Logger.toFile(formatter(id), file, { flag: "a" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const stderrLogger = Logger.make((options) => process.stderr.write(formatter().log(options) + "\n"))
|
const stderrLogger = Logger.make((options) => process.stderr.write(formatter().log(options) + "\n"))
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export * as PluginV2 from "./plugin"
|
|||||||
|
|
||||||
import { makeLocationNode } from "./effect/app-node"
|
import { makeLocationNode } from "./effect/app-node"
|
||||||
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
|
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
|
||||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/v2/effect"
|
import type { Plugin as PluginRuntime } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||||
import { AgentV2 } from "./agent"
|
import { AgentV2 } from "./agent"
|
||||||
import { AISDK } from "./aisdk"
|
import { AISDK } from "./aisdk"
|
||||||
@@ -11,20 +11,17 @@ import { CommandV2 } from "./command"
|
|||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { Integration } from "./integration"
|
import { Integration } from "./integration"
|
||||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||||
import { Location } from "./location"
|
|
||||||
import { PluginHost } from "./plugin/host"
|
import { PluginHost } from "./plugin/host"
|
||||||
import { PluginRuntime } from "./plugin/runtime"
|
|
||||||
import { Reference } from "./reference"
|
import { Reference } from "./reference"
|
||||||
import { SkillV2 } from "./skill"
|
import { SkillV2 } from "./skill"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
import { ToolRegistry } from "./tool/registry"
|
|
||||||
|
|
||||||
export const ID = Plugin.ID
|
export const ID = Plugin.ID
|
||||||
export type ID = typeof ID.Type
|
export type ID = typeof ID.Type
|
||||||
export const Event = Plugin.Event
|
export const Event = Plugin.Event
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly add: (id: ID, effect: PluginDefinition["effect"]) => Effect.Effect<void>
|
readonly add: (id: ID, effect: PluginRuntime["effect"]) => Effect.Effect<void>
|
||||||
readonly remove: (id: ID) => Effect.Effect<void>
|
readonly remove: (id: ID) => Effect.Effect<void>
|
||||||
readonly wait: (id: ID) => Effect.Effect<void>
|
readonly wait: (id: ID) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
@@ -41,9 +38,9 @@ export const layer = Layer.effect(
|
|||||||
const loading = new Set<ID>()
|
const loading = new Set<ID>()
|
||||||
const waiters = new Map<ID, Set<Deferred.Deferred<void>>>()
|
const waiters = new Map<ID, Set<Deferred.Deferred<void>>>()
|
||||||
const failures = new Map<ID, Exit.Exit<void, never>>()
|
const failures = new Map<ID, Exit.Exit<void, never>>()
|
||||||
let host: Parameters<PluginDefinition["effect"]>[0]
|
let host: Parameters<PluginRuntime["effect"]>[0]
|
||||||
|
|
||||||
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginDefinition["effect"]) {
|
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginRuntime["effect"]) {
|
||||||
if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)
|
if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)
|
||||||
|
|
||||||
yield* locks.withLock(id)(
|
yield* locks.withLock(id)(
|
||||||
@@ -153,8 +150,6 @@ export const locationLayer = layer.pipe(
|
|||||||
Layer.provideMerge(Integration.locationLayer),
|
Layer.provideMerge(Integration.locationLayer),
|
||||||
Layer.provideMerge(Reference.locationLayer),
|
Layer.provideMerge(Reference.locationLayer),
|
||||||
Layer.provideMerge(SkillV2.locationLayer),
|
Layer.provideMerge(SkillV2.locationLayer),
|
||||||
Layer.provideMerge(ToolRegistry.defaultLayer),
|
|
||||||
Layer.provideMerge(PluginRuntime.layer),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
@@ -167,10 +162,7 @@ export const node = makeLocationNode({
|
|||||||
Catalog.node,
|
Catalog.node,
|
||||||
CommandV2.node,
|
CommandV2.node,
|
||||||
Integration.node,
|
Integration.node,
|
||||||
Location.node,
|
|
||||||
Reference.node,
|
Reference.node,
|
||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
ToolRegistry.toolsNode,
|
|
||||||
PluginRuntime.node,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import { Global } from "../global"
|
|||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
|
|
||||||
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
|
const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*")
|
||||||
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
|
|
||||||
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
|
|
||||||
const BUILD_SYSTEM =
|
const BUILD_SYSTEM =
|
||||||
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
|
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
|
||||||
|
|
||||||
@@ -104,7 +102,7 @@ export const Plugin = define({
|
|||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const worktree = location.directory
|
const worktree = location.directory
|
||||||
const whitelistedDirs = [SHELL_OUTPUT_GLOB, path.join(Global.Path.tmp, "*")]
|
const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")]
|
||||||
const readonlyExternalDirectory: PermissionV2.Ruleset = [
|
const readonlyExternalDirectory: PermissionV2.Ruleset = [
|
||||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||||
...whitelistedDirs.map(
|
...whitelistedDirs.map(
|
||||||
@@ -126,6 +124,7 @@ export const Plugin = define({
|
|||||||
yield* ctx.agent.transform((draft) => {
|
yield* ctx.agent.transform((draft) => {
|
||||||
draft.update(AgentV2.defaultID, (item) => {
|
draft.update(AgentV2.defaultID, (item) => {
|
||||||
item.description = "The default agent. Executes tools based on configured permissions."
|
item.description = "The default agent. Executes tools based on configured permissions."
|
||||||
|
item.system ??= BUILD_SYSTEM
|
||||||
item.mode = "primary"
|
item.mode = "primary"
|
||||||
item.permissions.push(
|
item.permissions.push(
|
||||||
...PermissionV2.merge(defaults, [
|
...PermissionV2.merge(defaults, [
|
||||||
@@ -158,12 +157,7 @@ export const Plugin = define({
|
|||||||
item.description =
|
item.description =
|
||||||
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
|
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
|
||||||
item.mode = "subagent"
|
item.mode = "subagent"
|
||||||
item.permissions.push(
|
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }]))
|
||||||
...PermissionV2.merge(defaults, [
|
|
||||||
{ action: "subagent", resource: "*", effect: "deny" },
|
|
||||||
{ action: "todowrite", resource: "*", effect: "deny" },
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
draft.update(AgentV2.ID.make("explore"), (item) => {
|
draft.update(AgentV2.ID.make("explore"), (item) => {
|
||||||
@@ -181,7 +175,6 @@ export const Plugin = define({
|
|||||||
{ action: "webfetch", resource: "*", effect: "allow" },
|
{ action: "webfetch", resource: "*", effect: "allow" },
|
||||||
{ action: "websearch", resource: "*", effect: "allow" },
|
{ action: "websearch", resource: "*", effect: "allow" },
|
||||||
{ action: "read", resource: "*", effect: "allow" },
|
{ action: "read", resource: "*", effect: "allow" },
|
||||||
{ action: "subagent", resource: "*", effect: "deny" },
|
|
||||||
],
|
],
|
||||||
readonlyExternalDirectory,
|
readonlyExternalDirectory,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,16 +8,12 @@ import { Catalog } from "../catalog"
|
|||||||
import { CommandV2 } from "../command"
|
import { CommandV2 } from "../command"
|
||||||
import { Credential } from "../credential"
|
import { Credential } from "../credential"
|
||||||
import { Integration } from "../integration"
|
import { Integration } from "../integration"
|
||||||
import { Location } from "../location"
|
|
||||||
import { ModelV2 } from "../model"
|
import { ModelV2 } from "../model"
|
||||||
import { PluginV2 } from "../plugin"
|
import { PluginV2 } from "../plugin"
|
||||||
import { PluginRuntime } from "./runtime"
|
|
||||||
import { ProviderV2 } from "../provider"
|
import { ProviderV2 } from "../provider"
|
||||||
import { Reference } from "../reference"
|
import { Reference } from "../reference"
|
||||||
import { AbsolutePath, type DeepMutable } from "../schema"
|
import type { DeepMutable } from "../schema"
|
||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
import { Tools } from "../tool/tools"
|
|
||||||
import { WorkspaceV2 } from "../workspace"
|
|
||||||
|
|
||||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||||
|
|
||||||
@@ -27,38 +23,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const commands = yield* CommandV2.Service
|
const commands = yield* CommandV2.Service
|
||||||
const integration = yield* Integration.Service
|
const integration = yield* Integration.Service
|
||||||
const location = yield* Location.Service
|
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
const skill = yield* SkillV2.Service
|
const skill = yield* SkillV2.Service
|
||||||
const tools = yield* Tools.Service
|
|
||||||
const runtime = yield* PluginRuntime.Service
|
|
||||||
const locationInfo = () =>
|
|
||||||
new Location.Info({
|
|
||||||
directory: location.directory,
|
|
||||||
workspaceID: location.workspaceID,
|
|
||||||
project: location.project,
|
|
||||||
})
|
|
||||||
const locationRef = (input?: Parameters<Interface["agent"]["list"]>[0]) =>
|
|
||||||
input?.location === undefined
|
|
||||||
? undefined
|
|
||||||
: Location.Ref.make({
|
|
||||||
directory: AbsolutePath.make(input.location.directory ?? location.directory),
|
|
||||||
workspaceID:
|
|
||||||
input.location.workspace === undefined
|
|
||||||
? location.workspaceID
|
|
||||||
: WorkspaceV2.ID.make(input.location.workspace),
|
|
||||||
})
|
|
||||||
const isCurrentLocation = (ref: Location.Ref) =>
|
|
||||||
ref.directory === location.directory && ref.workspaceID === location.workspaceID
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
options: {},
|
options: {},
|
||||||
agent: {
|
agent: {
|
||||||
list: (input) => {
|
|
||||||
const ref = locationRef(input)
|
|
||||||
if (ref && !isCurrentLocation(ref)) return runtime.location.agent.list(ref)
|
|
||||||
return agents.list().pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
|
||||||
},
|
|
||||||
reload: agents.reload,
|
reload: agents.reload,
|
||||||
transform: (callback) =>
|
transform: (callback) =>
|
||||||
agents.transform((draft) =>
|
agents.transform((draft) =>
|
||||||
@@ -245,21 +215,5 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
tool: {
|
|
||||||
register: (input) => tools.register(input),
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
create: (input) =>
|
|
||||||
runtime.session.create({
|
|
||||||
id: input?.id,
|
|
||||||
agent: input?.agent,
|
|
||||||
model: input?.model,
|
|
||||||
location:
|
|
||||||
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
|
||||||
}),
|
|
||||||
get: (input) => runtime.session.get(input.sessionID),
|
|
||||||
prompt: runtime.session.prompt,
|
|
||||||
interrupt: (input) => runtime.session.interrupt(input.sessionID),
|
|
||||||
},
|
|
||||||
} satisfies Interface
|
} satisfies Interface
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,28 +20,19 @@ import { FSUtil } from "../fs-util"
|
|||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Integration } from "../integration"
|
import { Integration } from "../integration"
|
||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
import { LocationMutation } from "../location-mutation"
|
|
||||||
import { ModelsDev } from "../models-dev"
|
import { ModelsDev } from "../models-dev"
|
||||||
import { Npm } from "../npm"
|
import { Npm } from "../npm"
|
||||||
import { PluginV2 } from "../plugin"
|
import { PluginV2 } from "../plugin"
|
||||||
import { PluginRuntime } from "../plugin/runtime"
|
|
||||||
import { PermissionV2 } from "../permission"
|
|
||||||
import { Reference } from "../reference"
|
import { Reference } from "../reference"
|
||||||
import { Shell } from "../shell"
|
|
||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
import { State } from "../state"
|
import { State } from "../state"
|
||||||
import { ToolRegistry } from "../tool/registry"
|
|
||||||
import { Tools } from "../tool/tools"
|
|
||||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||||
import { AgentPlugin } from "./agent"
|
import { AgentPlugin } from "./agent"
|
||||||
import { CommandPlugin } from "./command"
|
import { CommandPlugin } from "./command"
|
||||||
import { ModelsDevPlugin } from "./models-dev"
|
import { ModelsDevPlugin } from "./models-dev"
|
||||||
import { ProviderPlugins } from "./provider"
|
import { ProviderPlugins } from "./provider"
|
||||||
import { SdkPlugins } from "./sdk"
|
|
||||||
import { SkillPlugin } from "./skill"
|
import { SkillPlugin } from "./skill"
|
||||||
import { VariantPlugin } from "./variant"
|
import { VariantPlugin } from "./variant"
|
||||||
import { ShellTool } from "../tool/shell"
|
|
||||||
import { SubagentTool } from "../tool/subagent"
|
|
||||||
|
|
||||||
export type Requirements =
|
export type Requirements =
|
||||||
| AgentV2.Service
|
| AgentV2.Service
|
||||||
@@ -55,15 +46,10 @@ export type Requirements =
|
|||||||
| HttpClient.HttpClient
|
| HttpClient.HttpClient
|
||||||
| Integration.Service
|
| Integration.Service
|
||||||
| Location.Service
|
| Location.Service
|
||||||
| LocationMutation.Service
|
|
||||||
| ModelsDev.Service
|
| ModelsDev.Service
|
||||||
| Npm.Service
|
| Npm.Service
|
||||||
| PermissionV2.Service
|
|
||||||
| PluginRuntime.Service
|
|
||||||
| Reference.Service
|
| Reference.Service
|
||||||
| Shell.Service
|
|
||||||
| SkillV2.Service
|
| SkillV2.Service
|
||||||
| Tools.Service
|
|
||||||
|
|
||||||
export interface Plugin<R = never> {
|
export interface Plugin<R = never> {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
@@ -79,7 +65,6 @@ const layer = Layer.effectDiscard(
|
|||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const commands = yield* CommandV2.Service
|
const commands = yield* CommandV2.Service
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const sdkPlugins = yield* SdkPlugins.Service
|
|
||||||
const integration = yield* Integration.Service
|
const integration = yield* Integration.Service
|
||||||
const agents = yield* AgentV2.Service
|
const agents = yield* AgentV2.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
@@ -91,13 +76,8 @@ const layer = Layer.effectDiscard(
|
|||||||
const filesystem = yield* FileSystem.Service
|
const filesystem = yield* FileSystem.Service
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const http = yield* HttpClient.HttpClient
|
const http = yield* HttpClient.HttpClient
|
||||||
const mutation = yield* LocationMutation.Service
|
|
||||||
const permission = yield* PermissionV2.Service
|
|
||||||
const skill = yield* SkillV2.Service
|
const skill = yield* SkillV2.Service
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
const shell = yield* Shell.Service
|
|
||||||
const tools = yield* Tools.Service
|
|
||||||
const runtime = yield* PluginRuntime.Service
|
|
||||||
const add = <R>(input: Plugin<R>) => {
|
const add = <R>(input: Plugin<R>) => {
|
||||||
const loaded = {
|
const loaded = {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
@@ -118,13 +98,8 @@ const layer = Layer.effectDiscard(
|
|||||||
Effect.provideService(FileSystem.Service, filesystem),
|
Effect.provideService(FileSystem.Service, filesystem),
|
||||||
Effect.provideService(Global.Service, global),
|
Effect.provideService(Global.Service, global),
|
||||||
Effect.provideService(HttpClient.HttpClient, http),
|
Effect.provideService(HttpClient.HttpClient, http),
|
||||||
Effect.provideService(LocationMutation.Service, mutation),
|
|
||||||
Effect.provideService(PermissionV2.Service, permission),
|
|
||||||
Effect.provideService(SkillV2.Service, skill),
|
Effect.provideService(SkillV2.Service, skill),
|
||||||
Effect.provideService(Reference.Service, reference),
|
Effect.provideService(Reference.Service, reference),
|
||||||
Effect.provideService(Shell.Service, shell),
|
|
||||||
Effect.provideService(Tools.Service, tools),
|
|
||||||
Effect.provideService(PluginRuntime.Service, runtime),
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
|
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
|
||||||
@@ -137,17 +112,13 @@ const layer = Layer.effectDiscard(
|
|||||||
yield* add(CommandPlugin.Plugin)
|
yield* add(CommandPlugin.Plugin)
|
||||||
yield* add(SkillPlugin.Plugin)
|
yield* add(SkillPlugin.Plugin)
|
||||||
yield* add(ModelsDevPlugin)
|
yield* add(ModelsDevPlugin)
|
||||||
yield* add(ConfigExternalPlugin.Plugin)
|
|
||||||
yield* add(ShellTool.Plugin)
|
|
||||||
yield* add(SubagentTool.Plugin)
|
|
||||||
yield* add(ConfigAgentPlugin.Plugin)
|
yield* add(ConfigAgentPlugin.Plugin)
|
||||||
yield* add(ConfigCommandPlugin.Plugin)
|
yield* add(ConfigCommandPlugin.Plugin)
|
||||||
yield* add(ConfigSkillPlugin.Plugin)
|
yield* add(ConfigSkillPlugin.Plugin)
|
||||||
for (const item of ProviderPlugins) yield* add(item)
|
for (const item of ProviderPlugins) yield* add(item)
|
||||||
|
yield* add(ConfigExternalPlugin.Plugin)
|
||||||
yield* add(ConfigProviderPlugin.Plugin)
|
yield* add(ConfigProviderPlugin.Plugin)
|
||||||
yield* add(VariantPlugin.Plugin)
|
yield* add(VariantPlugin.Plugin)
|
||||||
// Embedder-contributed plugins are added last so they layer over config.
|
|
||||||
for (const plugin of sdkPlugins.all()) yield* add(plugin)
|
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
||||||
}),
|
}),
|
||||||
@@ -171,7 +142,6 @@ export const node = makeLocationNode({
|
|||||||
AgentV2.node,
|
AgentV2.node,
|
||||||
Config.node,
|
Config.node,
|
||||||
Location.node,
|
Location.node,
|
||||||
LocationMutation.node,
|
|
||||||
ModelsDev.node,
|
ModelsDev.node,
|
||||||
Npm.node,
|
Npm.node,
|
||||||
EventV2.node,
|
EventV2.node,
|
||||||
@@ -179,12 +149,7 @@ export const node = makeLocationNode({
|
|||||||
FileSystem.node,
|
FileSystem.node,
|
||||||
Global.node,
|
Global.node,
|
||||||
httpClient,
|
httpClient,
|
||||||
PermissionV2.node,
|
|
||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
Reference.node,
|
Reference.node,
|
||||||
Shell.node,
|
|
||||||
ToolRegistry.toolsNode,
|
|
||||||
PluginRuntime.node,
|
|
||||||
SdkPlugins.node,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { define } from "./internal"
|
import { define } from "./internal"
|
||||||
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
|
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { ModelV2 } from "../model"
|
import { ModelV2 } from "../model"
|
||||||
@@ -38,42 +37,12 @@ function cost(input: ModelsDev.Model["cost"]) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
function variants(model: ModelsDev.Model, packageName: string | undefined): ModelV2Info["variants"] {
|
function variants(model: ModelsDev.Model) {
|
||||||
const result = new Map<ModelV2.VariantID, ModelV2Info["variants"][number]>()
|
return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => ({
|
||||||
if (packageName === "@ai-sdk/openai" || packageName === "@ai-sdk/openai-compatible") {
|
id: ModelV2.VariantID.make(id),
|
||||||
const option = model.reasoning_options?.find((option) => option.type === "effort")
|
headers: { ...(item.provider?.headers ?? {}) },
|
||||||
for (const value of option?.values ?? []) {
|
body: { ...(item.provider?.body ?? {}) },
|
||||||
const id = value === null ? "none" : value
|
}))
|
||||||
if (typeof id !== "string") continue
|
|
||||||
const variantID = ModelV2.VariantID.make(id)
|
|
||||||
result.set(variantID, {
|
|
||||||
id: variantID,
|
|
||||||
headers: {},
|
|
||||||
body:
|
|
||||||
packageName === "@ai-sdk/openai"
|
|
||||||
? { include: ["reasoning.encrypted_content"], reasoning: { effort: id, summary: "auto" } }
|
|
||||||
: { reasoning_effort: id },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const [id, item] of Object.entries(model.experimental?.modes ?? {})) {
|
|
||||||
const variantID = ModelV2.VariantID.make(id)
|
|
||||||
result.set(variantID, {
|
|
||||||
id: variantID,
|
|
||||||
headers: { ...(item.provider?.headers ?? {}) },
|
|
||||||
body: { ...(item.provider?.body ?? {}) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return [...result.values()]
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeVariants(model: ModelV2Info, next: ModelV2Info["variants"]) {
|
|
||||||
const existing = new Map(model.variants.map((variant) => [variant.id, variant]))
|
|
||||||
const nextIDs = new Set(next.map((variant) => variant.id))
|
|
||||||
model.variants = [
|
|
||||||
...next.map((variant) => existing.get(variant.id) ?? variant),
|
|
||||||
...model.variants.filter((variant) => !nextIDs.has(variant.id)),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ModelsDevPlugin = define({
|
export const ModelsDevPlugin = define({
|
||||||
@@ -142,7 +111,7 @@ export const ModelsDevPlugin = define({
|
|||||||
input: [...(model.modalities?.input ?? [])],
|
input: [...(model.modalities?.input ?? [])],
|
||||||
output: [...(model.modalities?.output ?? [])],
|
output: [...(model.modalities?.output ?? [])],
|
||||||
}
|
}
|
||||||
mergeVariants(draft, variants(model, model.provider?.npm ?? item.npm))
|
draft.variants = variants(model)
|
||||||
draft.time.released = released(model.release_date)
|
draft.time.released = released(model.release_date)
|
||||||
draft.cost = cost(model.cost)
|
draft.cost = cost(model.cost)
|
||||||
draft.status = model.status ?? "active"
|
draft.status = model.status ?? "active"
|
||||||
|
|||||||
@@ -142,16 +142,11 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
|||||||
Object.assign(model.request.headers, config.headers)
|
Object.assign(model.request.headers, config.headers)
|
||||||
Object.assign(model.request.body, lowerer.request(withoutCredentials(config.options)))
|
Object.assign(model.request.body, lowerer.request(withoutCredentials(config.options)))
|
||||||
if (config.variants !== undefined) {
|
if (config.variants !== undefined) {
|
||||||
for (const [id, options] of Object.entries(config.variants)) {
|
model.variants = Object.entries(config.variants).map(([id, options]) => ({
|
||||||
const variantID = ModelV2.VariantID.make(id)
|
id: ModelV2.VariantID.make(id),
|
||||||
let existing = model.variants.find((item) => item.id === variantID)
|
headers: { ...(options.headers ?? {}) },
|
||||||
if (!existing) {
|
body: lowerer.request(withoutCredentials(options)),
|
||||||
existing = { id: variantID, headers: {}, body: {} }
|
}))
|
||||||
model.variants.push(existing)
|
|
||||||
}
|
|
||||||
Object.assign(existing.headers, options.headers)
|
|
||||||
Object.assign(existing.body, lowerer.request(withoutCredentials(options)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (config.release_date !== undefined) {
|
if (config.release_date !== undefined) {
|
||||||
const released = Date.parse(config.release_date)
|
const released = Date.parse(config.release_date)
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
export * as PluginRuntime from "./runtime"
|
|
||||||
|
|
||||||
import { Context, Effect, Layer } from "effect"
|
|
||||||
import { AgentV2 } from "../agent"
|
|
||||||
import { makeGlobalNode } from "../effect/app-node"
|
|
||||||
import { Job } from "../job"
|
|
||||||
import { Location } from "../location"
|
|
||||||
import { LocationServiceMap } from "../location-service-map"
|
|
||||||
import { SessionV2 } from "../session"
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly session: Pick<
|
|
||||||
SessionV2.Interface,
|
|
||||||
"get" | "create" | "messages" | "prompt" | "resume" | "interrupt" | "synthetic"
|
|
||||||
>
|
|
||||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
|
||||||
readonly location: {
|
|
||||||
readonly agent: {
|
|
||||||
readonly list: (
|
|
||||||
ref: Location.Ref,
|
|
||||||
) => Effect.Effect<{ readonly location: Location.Info; readonly data: AgentV2.Info[] }>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginRuntime") {}
|
|
||||||
|
|
||||||
export interface Cell {
|
|
||||||
runtime?: Interface
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeCell = (): Cell => ({})
|
|
||||||
|
|
||||||
const unavailable = <A, E, R>() => Effect.die("Plugin runtime is unavailable") as Effect.Effect<A, E, R>
|
|
||||||
const require = <A, E, R>(cell: Cell, f: (runtime: Interface) => Effect.Effect<A, E, R>) =>
|
|
||||||
Effect.suspend(() => {
|
|
||||||
const runtime = cell.runtime
|
|
||||||
if (runtime === undefined) return unavailable<A, E, R>()
|
|
||||||
return f(runtime)
|
|
||||||
})
|
|
||||||
|
|
||||||
const defaultCell = makeCell()
|
|
||||||
|
|
||||||
export const layerWithCell = (cell: Cell) =>
|
|
||||||
Layer.succeed(
|
|
||||||
Service,
|
|
||||||
Service.of({
|
|
||||||
session: {
|
|
||||||
get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)),
|
|
||||||
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
|
|
||||||
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
|
|
||||||
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
|
|
||||||
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
|
|
||||||
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
|
|
||||||
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
|
|
||||||
},
|
|
||||||
job: {
|
|
||||||
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
|
|
||||||
wait: (input) => require(cell, (runtime) => runtime.job.wait(input)),
|
|
||||||
block: (input) => require(cell, (runtime) => runtime.job.block(input)),
|
|
||||||
background: (id) => require(cell, (runtime) => runtime.job.background(id)),
|
|
||||||
cancel: (id) => require(cell, (runtime) => runtime.job.cancel(id)),
|
|
||||||
},
|
|
||||||
location: {
|
|
||||||
agent: {
|
|
||||||
list: (ref) => require(cell, (runtime) => runtime.location.agent.list(ref)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const providerLayerWithCell = (cell: Cell) =>
|
|
||||||
Layer.effectDiscard(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const sessions = yield* SessionV2.Service
|
|
||||||
const jobs = yield* Job.Service
|
|
||||||
const locations = yield* LocationServiceMap.Service
|
|
||||||
const runtime = {
|
|
||||||
session: sessions,
|
|
||||||
job: jobs,
|
|
||||||
location: {
|
|
||||||
agent: {
|
|
||||||
list: (ref) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const location = yield* Location.Service
|
|
||||||
const agents = yield* AgentV2.Service
|
|
||||||
return {
|
|
||||||
location: new Location.Info({
|
|
||||||
directory: location.directory,
|
|
||||||
workspaceID: location.workspaceID,
|
|
||||||
project: location.project,
|
|
||||||
}),
|
|
||||||
data: yield* agents.list(),
|
|
||||||
}
|
|
||||||
}).pipe(Effect.provide(locations.get(ref))),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies Interface
|
|
||||||
cell.runtime = runtime
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
if (cell.runtime === runtime) cell.runtime = undefined
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const layer = layerWithCell(defaultCell)
|
|
||||||
export const providerLayer = providerLayerWithCell(defaultCell)
|
|
||||||
|
|
||||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
|
||||||
|
|
||||||
export const providerNode = makeGlobalNode({
|
|
||||||
name: "plugin-runtime-provider",
|
|
||||||
layer: providerLayer,
|
|
||||||
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
|
|
||||||
})
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
export * as SdkPlugins from "./sdk"
|
|
||||||
|
|
||||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
|
||||||
import { Context, Effect, Layer } from "effect"
|
|
||||||
import { makeGlobalNode } from "../effect/app-node"
|
|
||||||
|
|
||||||
export interface Store {
|
|
||||||
readonly plugins: Map<string, Plugin>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeStore = (): Store => ({ plugins: new Map() })
|
|
||||||
|
|
||||||
const defaultStore = makeStore()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes,
|
|
||||||
* so `PluginInternal` can add them on every Location boot through the ordinary
|
|
||||||
* `ctx.plugin.add` seam — the same path `ConfigExternalPlugin` uses for plugins
|
|
||||||
* discovered from config. A plugin registered after a Location has booted only
|
|
||||||
* applies to Locations booted afterward, matching config-plugin timing;
|
|
||||||
* embedders register at startup before creating Sessions.
|
|
||||||
*
|
|
||||||
* The store is shared explicitly between the SDK construction graph and the
|
|
||||||
* embedded route graph because `LocationServiceMap` builds Location layers lazily
|
|
||||||
* in a nested graph. Each embedded SDK creates its own store, so instances do not
|
|
||||||
* see each other's contributions.
|
|
||||||
*/
|
|
||||||
export interface Interface {
|
|
||||||
readonly register: (plugin: Plugin) => Effect.Effect<void>
|
|
||||||
readonly all: () => readonly Plugin[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SdkPlugins") {}
|
|
||||||
|
|
||||||
export const layerWithStore = (store: Store) =>
|
|
||||||
Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
store.plugins.clear()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return Service.of({
|
|
||||||
register: (plugin) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
store.plugins.set(plugin.id, plugin)
|
|
||||||
}),
|
|
||||||
all: () => [...store.plugins.values()],
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const layer = layerWithStore(defaultStore)
|
|
||||||
|
|
||||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
|
||||||
@@ -6,112 +6,26 @@ import { define } from "./internal"
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { AbsolutePath } from "../schema"
|
import { AbsolutePath } from "../schema"
|
||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
import { InstallationChannel, InstallationVersion } from "../installation/version"
|
|
||||||
import { Config } from "../config"
|
|
||||||
import { Location } from "../location"
|
|
||||||
import { FSUtil } from "../fs-util"
|
|
||||||
import os from "os"
|
|
||||||
import path from "path"
|
|
||||||
import { fileURLToPath } from "url"
|
|
||||||
import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" }
|
import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" }
|
||||||
import reportContent from "./skill/report.md" with { type: "text" }
|
|
||||||
|
|
||||||
export const CustomizeOpencodeContent = customizeOpencodeContent
|
export const CustomizeOpencodeContent = customizeOpencodeContent
|
||||||
export const ReportContent = reportContent
|
|
||||||
|
|
||||||
const CUSTOMIZE_OPENCODE_DESCRIPTION =
|
|
||||||
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself."
|
|
||||||
const REPORT_DESCRIPTION =
|
|
||||||
"Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI."
|
|
||||||
|
|
||||||
export const Plugin = define({
|
export const Plugin = define({
|
||||||
id: "skill",
|
id: "skill",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const reportContent = yield* reportContentWithDiagnostics()
|
|
||||||
yield* ctx.skill.transform((draft) => {
|
yield* ctx.skill.transform((draft) => {
|
||||||
draft.source(
|
draft.source(
|
||||||
SkillV2.EmbeddedSource.make({
|
SkillV2.EmbeddedSource.make({
|
||||||
type: "embedded",
|
type: "embedded",
|
||||||
skill: SkillV2.Info.make({
|
skill: SkillV2.Info.make({
|
||||||
name: "customize-opencode",
|
name: "customize-opencode",
|
||||||
description: CUSTOMIZE_OPENCODE_DESCRIPTION,
|
description:
|
||||||
|
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
|
||||||
location: AbsolutePath.make("/builtin/customize-opencode.md"),
|
location: AbsolutePath.make("/builtin/customize-opencode.md"),
|
||||||
content: CustomizeOpencodeContent,
|
content: CustomizeOpencodeContent,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
draft.source(
|
|
||||||
SkillV2.EmbeddedSource.make({
|
|
||||||
type: "embedded",
|
|
||||||
skill: SkillV2.Info.make({
|
|
||||||
name: "report",
|
|
||||||
description: REPORT_DESCRIPTION,
|
|
||||||
slash: true,
|
|
||||||
location: AbsolutePath.make("/builtin/report.md"),
|
|
||||||
content: reportContent,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* () {
|
|
||||||
const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"]))
|
|
||||||
return [
|
|
||||||
ReportContent,
|
|
||||||
"",
|
|
||||||
"## Runtime Diagnostics Snapshot",
|
|
||||||
"",
|
|
||||||
"These values were captured when the built-in report skill was registered. Verify them before publishing.",
|
|
||||||
"",
|
|
||||||
`- opencode version: ${InstallationVersion}`,
|
|
||||||
`- install/channel: ${InstallationChannel}`,
|
|
||||||
`- OS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()})`,
|
|
||||||
`- Terminal: ${terminal()}`,
|
|
||||||
`- Shell: ${shell()}`,
|
|
||||||
`- Active plugins: ${plugins.length === 0 ? "None found in config" : plugins.join(", ")}`,
|
|
||||||
].join("\n")
|
|
||||||
})
|
|
||||||
|
|
||||||
const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () {
|
|
||||||
const config = yield* Config.Service
|
|
||||||
const fs = yield* FSUtil.Service
|
|
||||||
const location = yield* Location.Service
|
|
||||||
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
|
||||||
if (entry.type === "document") {
|
|
||||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
|
||||||
return Effect.succeed(
|
|
||||||
(entry.info.plugins ?? []).map((item) => {
|
|
||||||
const ref = typeof item === "string" ? { package: item } : item
|
|
||||||
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
|
|
||||||
if (ref.package.startsWith("./") || ref.package.startsWith("../")) return path.resolve(directory, ref.package)
|
|
||||||
return ref.package
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return fs
|
|
||||||
.glob("{plugin,plugins}/*.{ts,js}", {
|
|
||||||
cwd: entry.path,
|
|
||||||
absolute: true,
|
|
||||||
include: "file",
|
|
||||||
dot: true,
|
|
||||||
symlink: true,
|
|
||||||
})
|
|
||||||
.pipe(Effect.orElseSucceed(() => []))
|
|
||||||
}).pipe(Effect.map((items) => items.flat().toSorted()))
|
|
||||||
})
|
|
||||||
|
|
||||||
function terminal() {
|
|
||||||
return [
|
|
||||||
process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined,
|
|
||||||
process.env.TERM ? `TERM=${process.env.TERM}` : undefined,
|
|
||||||
process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined,
|
|
||||||
]
|
|
||||||
.filter((item): item is string => item !== undefined)
|
|
||||||
.join(", ") || "Unavailable: terminal environment variables are not set"
|
|
||||||
}
|
|
||||||
|
|
||||||
function shell() {
|
|
||||||
return process.env.SHELL ?? process.env.ComSpec ?? process.env.COMSPEC ?? "Unavailable: shell environment variable is not set"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
<!--
|
|
||||||
Built-in skill. Name and description are registered in code at
|
|
||||||
packages/core/src/plugin/skill.ts. The body below becomes the skill's
|
|
||||||
content.
|
|
||||||
-->
|
|
||||||
|
|
||||||
# Report an opencode Issue
|
|
||||||
|
|
||||||
Use this skill when the user wants to report an opencode issue or bug. Your job
|
|
||||||
is to turn the user's problem into a useful GitHub issue with standard
|
|
||||||
diagnostics plus the context needed to reproduce and resolve it.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. Collect the standard diagnostics below.
|
|
||||||
2. Ask only for missing details that are necessary to reproduce or understand
|
|
||||||
impact.
|
|
||||||
3. Draft the issue in the standard format below.
|
|
||||||
4. Publish it with GitHub CLI after the user confirms the title and body.
|
|
||||||
|
|
||||||
Do not publish an issue without user confirmation. If GitHub CLI is not
|
|
||||||
installed or not authenticated, explain the blocker and provide the exact issue
|
|
||||||
title/body for the user.
|
|
||||||
|
|
||||||
## Standard Diagnostics
|
|
||||||
|
|
||||||
Collect these values when possible:
|
|
||||||
|
|
||||||
- opencode version: run `opencode --version` or `opencode2 --version`,
|
|
||||||
depending on the executable in use.
|
|
||||||
- Operating system: run `uname -a` on Unix-like systems, or `ver` on Windows.
|
|
||||||
- Terminal: inspect `$TERM`, `$TERM_PROGRAM`, `$COLORTERM`, and any obvious
|
|
||||||
terminal app context the user provides.
|
|
||||||
- Shell: inspect `$SHELL` on Unix-like systems, or `%COMSPEC%`/`$ComSpec` on
|
|
||||||
Windows when relevant.
|
|
||||||
- Install/channel context: include whether this appears to be local, dev, beta,
|
|
||||||
or release if the version output or environment reveals it.
|
|
||||||
- Active plugins: inspect opencode config for configured plugins when possible.
|
|
||||||
Check likely config locations such as `opencode.json`, `opencode.jsonc`,
|
|
||||||
`.opencode/opencode.json`, and `~/.config/opencode/opencode.json`. Record
|
|
||||||
configured plugin entries, local plugin files under `.opencode/plugin/` or
|
|
||||||
`.opencode/plugins/`, and note if plugin status could not be determined.
|
|
||||||
|
|
||||||
If a diagnostic command fails, include `Unavailable` with the reason instead of
|
|
||||||
guessing.
|
|
||||||
|
|
||||||
## User-Specific Context
|
|
||||||
|
|
||||||
Capture the details that make the issue actionable:
|
|
||||||
|
|
||||||
- What the user was trying to do.
|
|
||||||
- What happened.
|
|
||||||
- What the user expected to happen.
|
|
||||||
- Reproduction steps, ideally minimal and numbered.
|
|
||||||
- Relevant logs, stack traces, screenshots, terminal output, or config snippets.
|
|
||||||
- Whether the issue is reproducible consistently, intermittently, or only once.
|
|
||||||
- Recent changes that may be related, such as updating opencode, changing
|
|
||||||
config, installing a plugin, changing terminal, or switching workspace.
|
|
||||||
- Workarounds tried and whether they helped.
|
|
||||||
|
|
||||||
Avoid pasting secrets. Redact tokens, API keys, private URLs, usernames, and
|
|
||||||
project-specific confidential data unless the user explicitly says it is safe.
|
|
||||||
|
|
||||||
## Issue Format
|
|
||||||
|
|
||||||
Use this exact structure unless the repository issue template requires
|
|
||||||
otherwise:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
<!-- One or two sentences describing the bug and impact. -->
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
- opencode version: <!-- value or Unavailable: reason -->
|
|
||||||
- OS: <!-- value or Unavailable: reason -->
|
|
||||||
- Terminal: <!-- value or Unavailable: reason -->
|
|
||||||
- Shell: <!-- value or Unavailable: reason -->
|
|
||||||
- Install/channel: <!-- value or Unavailable: reason -->
|
|
||||||
- Active plugins: <!-- list, none found, or Unavailable: reason -->
|
|
||||||
|
|
||||||
## Reproduction
|
|
||||||
|
|
||||||
1. <!-- step -->
|
|
||||||
2. <!-- step -->
|
|
||||||
3. <!-- step -->
|
|
||||||
|
|
||||||
## Expected Behavior
|
|
||||||
|
|
||||||
<!-- What should have happened. -->
|
|
||||||
|
|
||||||
## Actual Behavior
|
|
||||||
|
|
||||||
<!-- What happened instead. Include exact errors when available. -->
|
|
||||||
|
|
||||||
## Additional Context
|
|
||||||
|
|
||||||
<!-- Logs, config snippets, screenshots, frequency, workarounds, related notes. -->
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep the title short and searchable. Prefer the form:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<area>: <specific failure or symptom>
|
|
||||||
```
|
|
||||||
|
|
||||||
Examples: `tui: skills dialog crashes outside location provider`,
|
|
||||||
`cli: local service config writes release filename`.
|
|
||||||
|
|
||||||
## Publishing With GitHub CLI
|
|
||||||
|
|
||||||
Use GitHub CLI from the repository checkout when available:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
gh issue create --title "<title>" --body-file <file>
|
|
||||||
```
|
|
||||||
|
|
||||||
Write the body to a temporary markdown file first so quoting, newlines, logs,
|
|
||||||
and code fences are preserved. If the issue belongs in a specific repository,
|
|
||||||
use `--repo owner/name`. If labels are obvious and the repo accepts them, add
|
|
||||||
`--label bug`; otherwise omit labels rather than guessing.
|
|
||||||
|
|
||||||
After publishing, report the created issue URL to the user and mention any
|
|
||||||
diagnostics that were unavailable.
|
|
||||||
@@ -17,20 +17,14 @@ export type ID = ProjectSchema.ID
|
|||||||
export const Vcs = ProjectSchema.Vcs
|
export const Vcs = ProjectSchema.Vcs
|
||||||
export type Vcs = ProjectSchema.Vcs
|
export type Vcs = ProjectSchema.Vcs
|
||||||
|
|
||||||
export const Current = ProjectSchema.Current
|
|
||||||
export type Current = ProjectSchema.Current
|
|
||||||
|
|
||||||
export const Directory = ProjectSchema.Directory
|
|
||||||
export type Directory = ProjectSchema.Directory
|
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Project.Info")({
|
export class Info extends Schema.Class<Info>("Project.Info")({
|
||||||
id: ID,
|
id: ID,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const DirectoriesInput = ProjectSchema.DirectoriesInput
|
export const DirectoriesInput = ProjectDirectories.ListInput
|
||||||
export type DirectoriesInput = typeof DirectoriesInput.Type
|
export type DirectoriesInput = typeof DirectoriesInput.Type
|
||||||
|
|
||||||
export const Directories = ProjectSchema.Directories
|
export const Directories = ProjectDirectories.ListOutput
|
||||||
export type Directories = typeof Directories.Type
|
export type Directories = typeof Directories.Type
|
||||||
|
|
||||||
export interface Resolved {
|
export interface Resolved {
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
|||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { makeGlobalNode } from "../effect/app-node"
|
import { makeGlobalNode } from "../effect/app-node"
|
||||||
import { AbsolutePath } from "../schema"
|
import { AbsolutePath, optional } from "../schema"
|
||||||
import { ProjectSchema } from "./schema"
|
import { ProjectSchema } from "./schema"
|
||||||
import { ProjectDirectoryTable } from "./sql"
|
import { ProjectDirectoryTable } from "./sql"
|
||||||
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||||
import type { Project } from "../project"
|
|
||||||
|
|
||||||
export type Directory = Project.Directory
|
export interface Directory {
|
||||||
|
readonly directory: AbsolutePath
|
||||||
|
readonly strategy?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const CreateInput = Schema.Struct({
|
export const CreateInput = Schema.Struct({
|
||||||
projectID: ProjectSchema.ID,
|
projectID: ProjectSchema.ID,
|
||||||
@@ -29,10 +31,17 @@ export type RemoveInput = typeof RemoveInput.Type
|
|||||||
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
|
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||||
export type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
|
export type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
|
||||||
|
|
||||||
export const ListInput = ProjectSchema.DirectoriesInput
|
export const ListInput = Schema.Struct({
|
||||||
|
projectID: ProjectSchema.ID,
|
||||||
|
}).annotate({ identifier: "Project.DirectoriesInput" })
|
||||||
export type ListInput = typeof ListInput.Type
|
export type ListInput = typeof ListInput.Type
|
||||||
|
|
||||||
export const ListOutput = ProjectSchema.Directories
|
export const ListOutput = Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
directory: AbsolutePath,
|
||||||
|
strategy: optional(Schema.String),
|
||||||
|
}),
|
||||||
|
).annotate({ identifier: "Project.Directories" })
|
||||||
export type ListOutput = typeof ListOutput.Type
|
export type ListOutput = typeof ListOutput.Type
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
|||||||
@@ -7,18 +7,6 @@ import { AbsolutePath } from "../schema"
|
|||||||
export const ID = Project.ID
|
export const ID = Project.ID
|
||||||
export type ID = typeof ID.Type
|
export type ID = typeof ID.Type
|
||||||
|
|
||||||
export const Current = Project.Current
|
|
||||||
export type Current = typeof Current.Type
|
|
||||||
|
|
||||||
export const Directory = Project.Directory
|
|
||||||
export type Directory = typeof Directory.Type
|
|
||||||
|
|
||||||
export const DirectoriesInput = Project.DirectoriesInput
|
|
||||||
export type DirectoriesInput = typeof DirectoriesInput.Type
|
|
||||||
|
|
||||||
export const Directories = Project.Directories
|
|
||||||
export type Directories = typeof Directories.Type
|
|
||||||
|
|
||||||
export const Vcs = Schema.Union([
|
export const Vcs = Schema.Union([
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.Literal("git"),
|
type: Schema.Literal("git"),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Config } from "./config"
|
|||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { PtyID } from "./pty/schema"
|
import { PtyID } from "./pty/schema"
|
||||||
import { ShellSelect } from "./shell/select"
|
import { Shell } from "./shell"
|
||||||
import { lazy } from "./util/lazy"
|
import { lazy } from "./util/lazy"
|
||||||
|
|
||||||
const BUFFER_LIMIT = 1024 * 1024 * 2
|
const BUFFER_LIMIT = 1024 * 1024 * 2
|
||||||
@@ -164,8 +164,8 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||||
const id = PtyID.ascending()
|
const id = PtyID.ascending()
|
||||||
const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"))
|
const command = input.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
|
||||||
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
|
const args = Shell.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
|
||||||
const cwd = input.cwd || location.directory
|
const cwd = input.cwd || location.directory
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export * as PublicEventManifest from "./public-event-manifest"
|
||||||
|
|
||||||
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
|
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||||
|
|
||||||
|
export const Definitions = EventManifest.ServerDefinitions
|
||||||
|
export const Latest = Event.latest(Definitions)
|
||||||
+45
-148
@@ -1,7 +1,7 @@
|
|||||||
export * as SessionV2 from "./session"
|
export * as SessionV2 from "./session"
|
||||||
export * from "./session/schema"
|
export * from "./session/schema"
|
||||||
|
|
||||||
import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
|
||||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||||
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
|
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
|
||||||
import { ProjectV2 } from "./project"
|
import { ProjectV2 } from "./project"
|
||||||
@@ -33,12 +33,10 @@ import { MessageDecodeError } from "./session/error"
|
|||||||
import { SessionEvent } from "./session/event"
|
import { SessionEvent } from "./session/event"
|
||||||
import { SessionInput } from "./session/input"
|
import { SessionInput } from "./session/input"
|
||||||
import { Snapshot } from "./snapshot"
|
import { Snapshot } from "./snapshot"
|
||||||
import { SessionCompaction } from "./session/compaction"
|
|
||||||
import { SessionRevert } from "./session/revert"
|
import { SessionRevert } from "./session/revert"
|
||||||
import { Revert } from "@opencode-ai/schema/revert"
|
import { Revert } from "@opencode-ai/schema/revert"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
|
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
|
||||||
import { SkillV2 } from "./skill"
|
|
||||||
|
|
||||||
export const RevertState = Revert.State
|
export const RevertState = Revert.State
|
||||||
export type RevertState = Revert.State
|
export type RevertState = Revert.State
|
||||||
@@ -78,22 +76,16 @@ const ListAllInput = Schema.Struct(ListInputBase)
|
|||||||
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
|
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
|
||||||
export type ListInput = typeof ListInput.Type
|
export type ListInput = typeof ListInput.Type
|
||||||
|
|
||||||
type CreateBaseInput = {
|
type CreateInput = {
|
||||||
id?: SessionSchema.ID
|
id?: SessionSchema.ID
|
||||||
title?: string
|
|
||||||
agent?: AgentV2.ID
|
agent?: AgentV2.ID
|
||||||
model?: ModelV2.Ref
|
model?: ModelV2.Ref
|
||||||
|
location: Location.Ref
|
||||||
}
|
}
|
||||||
type CreateInput = CreateBaseInput &
|
|
||||||
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
|
|
||||||
|
|
||||||
type CompactInput = {
|
type CompactInput = {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
}
|
prompt?: Prompt
|
||||||
|
|
||||||
type ForkInput = {
|
|
||||||
sessionID: SessionSchema.ID
|
|
||||||
messageID?: SessionMessage.ID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
|
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
|
||||||
@@ -103,7 +95,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
|
|||||||
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
|
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
|
||||||
"Session.OperationUnavailableError",
|
"Session.OperationUnavailableError",
|
||||||
{
|
{
|
||||||
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact"]),
|
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact", "wait"]),
|
||||||
},
|
},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -113,28 +105,14 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
|
|||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
messageID: SessionMessage.ID,
|
messageID: SessionMessage.ID,
|
||||||
}) {}
|
}) {}
|
||||||
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
}) {}
|
|
||||||
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
|
||||||
skill: Schema.String,
|
|
||||||
}) {}
|
|
||||||
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||||
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||||
|
|
||||||
export type Error =
|
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError
|
||||||
| NotFoundError
|
|
||||||
| MessageDecodeError
|
|
||||||
| OperationUnavailableError
|
|
||||||
| PromptConflictError
|
|
||||||
| BusyError
|
|
||||||
| SkillNotFoundError
|
|
||||||
| MessageNotFoundError
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
|
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
|
||||||
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info>
|
||||||
readonly fork: (input: ForkInput) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError>
|
|
||||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||||
readonly messages: (input: {
|
readonly messages: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
@@ -166,7 +144,6 @@ export interface Interface {
|
|||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
model: ModelV2.Ref
|
model: ModelV2.Ref
|
||||||
}) => Effect.Effect<void, NotFoundError>
|
}) => Effect.Effect<void, NotFoundError>
|
||||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
|
||||||
readonly prompt: (input: {
|
readonly prompt: (input: {
|
||||||
id?: SessionMessage.ID
|
id?: SessionMessage.ID
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
@@ -181,27 +158,24 @@ export interface Interface {
|
|||||||
resume?: boolean
|
resume?: boolean
|
||||||
}) => Effect.Effect<void, OperationUnavailableError>
|
}) => Effect.Effect<void, OperationUnavailableError>
|
||||||
readonly skill: (input: {
|
readonly skill: (input: {
|
||||||
id?: SessionMessage.ID
|
id?: EventV2.ID
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
skill: string
|
skill: string
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
|
}) => Effect.Effect<void, OperationUnavailableError>
|
||||||
readonly compact: (
|
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||||
input: CompactInput,
|
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||||
) => Effect.Effect<void, NotFoundError | BusyError | MessageDecodeError | OperationUnavailableError>
|
|
||||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
|
||||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||||
readonly synthetic: (input: { sessionID: SessionSchema.ID; text: string }) => Effect.Effect<void, NotFoundError>
|
|
||||||
readonly revert: {
|
readonly revert: {
|
||||||
readonly stage: (input: {
|
readonly stage: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
messageID: SessionMessage.ID
|
messageID: SessionMessage.ID
|
||||||
files?: boolean
|
files?: boolean
|
||||||
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
|
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | Snapshot.Error>
|
||||||
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
|
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | Snapshot.Error>
|
||||||
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
|
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +191,6 @@ export const layer = Layer.effect(
|
|||||||
const execution = yield* SessionExecution.Service
|
const execution = yield* SessionExecution.Service
|
||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const locations = yield* LocationServiceMap.Service
|
const locations = yield* LocationServiceMap.Service
|
||||||
const scope = yield* Scope.Scope
|
|
||||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||||
@@ -236,12 +209,7 @@ export const layer = Layer.effect(
|
|||||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||||
const recorded = yield* store.get(sessionID)
|
const recorded = yield* store.get(sessionID)
|
||||||
if (recorded) return recorded
|
if (recorded) return recorded
|
||||||
const parent = input.parentID ? yield* store.get(input.parentID) : undefined
|
const project = yield* projects.resolve(input.location.directory)
|
||||||
if (input.parentID && parent === undefined) return yield* new NotFoundError({ sessionID: input.parentID })
|
|
||||||
const location = parent?.location ?? input.location
|
|
||||||
if (location === undefined)
|
|
||||||
return yield* Effect.die(new Error("V2Session.create requires either location or an existing parentID"))
|
|
||||||
const project = yield* projects.resolve(location.directory)
|
|
||||||
yield* db
|
yield* db
|
||||||
.insert(ProjectTable)
|
.insert(ProjectTable)
|
||||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
||||||
@@ -254,11 +222,10 @@ export const layer = Layer.effect(
|
|||||||
slug: Slug.create(),
|
slug: Slug.create(),
|
||||||
version: InstallationVersion,
|
version: InstallationVersion,
|
||||||
projectID: project.id,
|
projectID: project.id,
|
||||||
parentID: input.parentID,
|
directory: input.location.directory,
|
||||||
directory: location.directory,
|
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
|
||||||
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
|
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
|
||||||
workspaceID: location.workspaceID ? WorkspaceV2.ID.make(location.workspaceID) : undefined,
|
title: `New session - ${new Date(now).toISOString()}`,
|
||||||
title: input.title ?? `New session - ${new Date(now).toISOString()}`,
|
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model
|
model: input.model
|
||||||
? {
|
? {
|
||||||
@@ -271,49 +238,28 @@ export const layer = Layer.effect(
|
|||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
time: { created: now, updated: now },
|
time: { created: now, updated: now },
|
||||||
})
|
})
|
||||||
const projected = yield* events.publish(SessionV1.Event.Created, { sessionID, info }, { location }).pipe(
|
const projected = yield* events
|
||||||
Effect.as({ type: "created" } as const),
|
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
|
||||||
Effect.catchDefect((defect) => {
|
.pipe(
|
||||||
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
Effect.as({ type: "created" } as const),
|
||||||
return Effect.die(defect)
|
Effect.catchDefect((defect) => {
|
||||||
}
|
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
||||||
// Concurrent creation lost the projection race. The existing Session identity wins.
|
return Effect.die(defect)
|
||||||
return store
|
}
|
||||||
.get(sessionID)
|
// Concurrent creation lost the projection race. The existing Session identity wins.
|
||||||
.pipe(
|
return store
|
||||||
Effect.flatMap((session) =>
|
.get(sessionID)
|
||||||
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
.pipe(
|
||||||
),
|
Effect.flatMap((session) =>
|
||||||
)
|
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
||||||
}),
|
),
|
||||||
)
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
if (projected.type === "existing") return projected.session
|
if (projected.type === "existing") return projected.session
|
||||||
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
||||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
fork: Effect.fn("V2Session.fork")(function* (input) {
|
|
||||||
const parent = yield* result.get(input.sessionID)
|
|
||||||
const boundary = input.messageID
|
|
||||||
? yield* db
|
|
||||||
.select({ seq: SessionMessageTable.seq })
|
|
||||||
.from(SessionMessageTable)
|
|
||||||
.where(
|
|
||||||
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID)),
|
|
||||||
)
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
: undefined
|
|
||||||
if (input.messageID && !boundary)
|
|
||||||
return yield* new MessageNotFoundError({ sessionID: input.sessionID, messageID: input.messageID })
|
|
||||||
const sessionID = SessionSchema.ID.create()
|
|
||||||
yield* events.publish(SessionEvent.Forked, {
|
|
||||||
sessionID,
|
|
||||||
parentID: parent.id,
|
|
||||||
messageID: input.messageID,
|
|
||||||
timestamp: yield* DateTime.now,
|
|
||||||
})
|
|
||||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
|
||||||
}),
|
|
||||||
get: Effect.fn("V2Session.get")(function* (sessionID) {
|
get: Effect.fn("V2Session.get")(function* (sessionID) {
|
||||||
const session = yield* store.get(sessionID)
|
const session = yield* store.get(sessionID)
|
||||||
if (!session) return yield* new NotFoundError({ sessionID })
|
if (!session) return yield* new NotFoundError({ sessionID })
|
||||||
@@ -414,11 +360,7 @@ export const layer = Layer.effect(
|
|||||||
prompt: Effect.fn("V2Session.prompt")((input) =>
|
prompt: Effect.fn("V2Session.prompt")((input) =>
|
||||||
Effect.uninterruptible(
|
Effect.uninterruptible(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
// A staged revert must be committed before admitting new input so the prompt
|
|
||||||
// continues from the reverted boundary rather than stale post-boundary history.
|
|
||||||
if (session.revert)
|
|
||||||
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
|
||||||
const prompt = resolvePrompt(input.prompt)
|
const prompt = resolvePrompt(input.prompt)
|
||||||
const messageID = input.id ?? SessionMessage.ID.create()
|
const messageID = input.id ?? SessionMessage.ID.create()
|
||||||
const delivery = input.delivery ?? "steer"
|
const delivery = input.delivery ?? "steer"
|
||||||
@@ -445,20 +387,8 @@ export const layer = Layer.effect(
|
|||||||
shell: Effect.fn("V2Session.shell")(function* () {
|
shell: Effect.fn("V2Session.shell")(function* () {
|
||||||
return yield* new OperationUnavailableError({ operation: "shell" })
|
return yield* new OperationUnavailableError({ operation: "shell" })
|
||||||
}),
|
}),
|
||||||
skill: Effect.fn("V2Session.skill")(function* (input) {
|
skill: Effect.fn("V2Session.skill")(function* () {
|
||||||
const session = yield* result.get(input.sessionID)
|
return yield* new OperationUnavailableError({ operation: "skill" })
|
||||||
const skills = yield* SkillV2.Service.pipe(Effect.provide(locations.get(session.location)))
|
|
||||||
const skill = (yield* skills.list()).find((item) => item.name === input.skill)
|
|
||||||
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
|
|
||||||
yield* events.publish(SessionEvent.Skill.Activated, {
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
messageID: input.id ?? SessionMessage.ID.create(),
|
|
||||||
timestamp: yield* DateTime.now,
|
|
||||||
name: skill.name,
|
|
||||||
text: skill.content,
|
|
||||||
})
|
|
||||||
if (input.resume !== false)
|
|
||||||
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
|
||||||
}),
|
}),
|
||||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||||
yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
@@ -484,56 +414,25 @@ export const layer = Layer.effect(
|
|||||||
model: input.model,
|
model: input.model,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
rename: Effect.fn("V2Session.rename")(function* (input) {
|
|
||||||
yield* result.get(input.sessionID)
|
|
||||||
yield* events.publish(SessionEvent.Renamed, {
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
timestamp: yield* DateTime.now,
|
|
||||||
title: input.title,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
compact: Effect.fn("V2Session.compact")(function* (input) {
|
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||||
const session = yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
// TODO: admit manual compaction as durable pending work, like prompt input, instead of rejecting active sessions.
|
return yield* new OperationUnavailableError({ operation: "compact" })
|
||||||
if ((yield* execution.active).has(input.sessionID)) return yield* new BusyError({ sessionID: input.sessionID })
|
|
||||||
const context = yield* store.context(input.sessionID)
|
|
||||||
const compacted = yield* Effect.gen(function* () {
|
|
||||||
const compaction = yield* SessionCompaction.Service
|
|
||||||
return yield* compaction.compactManual({ session, messages: context })
|
|
||||||
}).pipe(
|
|
||||||
Effect.provide(locations.get(session.location)),
|
|
||||||
Effect.catch(() => Effect.succeed(false)),
|
|
||||||
)
|
|
||||||
if (!compacted) return yield* new OperationUnavailableError({ operation: "compact" })
|
|
||||||
return undefined
|
|
||||||
}),
|
}),
|
||||||
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
||||||
yield* result.get(sessionID)
|
yield* result.get(sessionID)
|
||||||
yield* execution.awaitIdle(sessionID)
|
return yield* new OperationUnavailableError({ operation: "wait" })
|
||||||
}),
|
}),
|
||||||
active: execution.active,
|
active: execution.active,
|
||||||
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
||||||
yield* result.get(sessionID)
|
yield* result.get(sessionID)
|
||||||
yield* execution.resume(sessionID)
|
yield* execution.resume(sessionID)
|
||||||
}),
|
}),
|
||||||
synthetic: Effect.fn("V2Session.synthetic")(function* (input) {
|
|
||||||
yield* result.get(input.sessionID)
|
|
||||||
yield* events.publish(SessionEvent.Synthetic, {
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
messageID: SessionMessage.ID.create(),
|
|
||||||
timestamp: yield* DateTime.now,
|
|
||||||
text: input.text,
|
|
||||||
})
|
|
||||||
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
|
||||||
}),
|
|
||||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||||
),
|
),
|
||||||
revert: {
|
revert: {
|
||||||
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
|
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
|
||||||
const session = yield* result.get(input.sessionID)
|
const session = yield* result.get(input.sessionID)
|
||||||
if ((yield* execution.active).has(input.sessionID))
|
|
||||||
return yield* new BusyError({ sessionID: input.sessionID })
|
|
||||||
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
||||||
Effect.provideService(Database.Service, database),
|
Effect.provideService(Database.Service, database),
|
||||||
Effect.provideService(EventV2.Service, events),
|
Effect.provideService(EventV2.Service, events),
|
||||||
@@ -542,16 +441,14 @@ export const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
|
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
|
||||||
const session = yield* result.get(sessionID)
|
const session = yield* result.get(sessionID)
|
||||||
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
|
yield* SessionRevert.clear(session).pipe(
|
||||||
return yield* SessionRevert.clear(session).pipe(
|
|
||||||
Effect.provideService(EventV2.Service, events),
|
Effect.provideService(EventV2.Service, events),
|
||||||
Effect.provide(locations.get(session.location)),
|
Effect.provide(locations.get(session.location)),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
|
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
|
||||||
const session = yield* result.get(sessionID)
|
const session = yield* result.get(sessionID)
|
||||||
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
|
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
||||||
return yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
export * as SessionCompaction from "./compaction"
|
export * as SessionCompaction from "./compaction"
|
||||||
|
|
||||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
|
import { LLM, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
|
||||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
import { DateTime, Effect, Stream } from "effect"
|
||||||
import type { Config } from "../config"
|
import type { Config } from "../config"
|
||||||
import { Config as ConfigV2 } from "../config"
|
|
||||||
import type { EventV2 } from "../event"
|
import type { EventV2 } from "../event"
|
||||||
import { EventV2 as EventV2Service } from "../event"
|
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
|
||||||
import { llmClient } from "../effect/app-node-platform"
|
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
import { SessionRunnerModel } from "./runner/model"
|
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { Token } from "../util/token"
|
import { Token } from "../util/token"
|
||||||
|
|
||||||
@@ -55,6 +50,11 @@ Rules:
|
|||||||
- Preserve exact file paths, commands, error strings, and identifiers when known.
|
- Preserve exact file paths, commands, error strings, and identifiers when known.
|
||||||
- Do not mention the summary process or that context was compacted.`
|
- Do not mention the summary process or that context was compacted.`
|
||||||
|
|
||||||
|
type Entry = {
|
||||||
|
readonly seq: number
|
||||||
|
readonly message: SessionMessage.Message
|
||||||
|
}
|
||||||
|
|
||||||
type Settings = {
|
type Settings = {
|
||||||
readonly auto: boolean
|
readonly auto: boolean
|
||||||
readonly buffer: number
|
readonly buffer: number
|
||||||
@@ -69,31 +69,13 @@ type Dependencies = {
|
|||||||
readonly config: readonly Config.Entry[]
|
readonly config: readonly Config.Entry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AutoInput = {
|
type Input = {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly messages: readonly SessionMessage.Message[]
|
readonly entries: readonly Entry[]
|
||||||
|
readonly model: Model
|
||||||
readonly request: LLMRequest
|
readonly request: LLMRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
type CompactInput = {
|
|
||||||
readonly sessionID: SessionSchema.ID
|
|
||||||
readonly messages: readonly SessionMessage.Message[]
|
|
||||||
readonly model: Model
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ManualInput = {
|
|
||||||
readonly session: SessionSchema.Info
|
|
||||||
readonly messages: readonly SessionMessage.Message[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly compactIfNeeded: (input: AutoInput) => Effect.Effect<boolean>
|
|
||||||
readonly compactAfterOverflow: (input: AutoInput) => Effect.Effect<boolean>
|
|
||||||
readonly compactManual: (input: ManualInput) => Effect.Effect<boolean>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionCompaction") {}
|
|
||||||
|
|
||||||
const estimate = (value: unknown) => Token.estimate(JSON.stringify(value))
|
const estimate = (value: unknown) => Token.estimate(JSON.stringify(value))
|
||||||
|
|
||||||
const truncate = (value: string) =>
|
const truncate = (value: string) =>
|
||||||
@@ -130,7 +112,6 @@ const serialize = (message: SessionMessage.Message) => {
|
|||||||
}
|
}
|
||||||
if (message.type === "system") return `[System update]: ${message.text}`
|
if (message.type === "system") return `[System update]: ${message.text}`
|
||||||
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
|
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
|
||||||
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
|
|
||||||
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output)}`
|
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output)}`
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -150,14 +131,14 @@ const settings = (documents: readonly Config.Entry[]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const select = (
|
const select = (
|
||||||
messages: readonly SessionMessage.Message[],
|
entries: readonly Entry[],
|
||||||
tokens: number,
|
tokens: number,
|
||||||
): { readonly head: string; readonly recent: string } | undefined => {
|
): { readonly head: string; readonly recent: string } | undefined => {
|
||||||
const conversation = messages
|
const conversation = entries
|
||||||
.filter((message) => message.type !== "compaction")
|
.filter((entry) => entry.message.type !== "compaction")
|
||||||
.map(serialize)
|
.map((entry) => serialize(entry.message))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
if (conversation.length === 0) return undefined
|
if (conversation.length === 0) return
|
||||||
let total = 0
|
let total = 0
|
||||||
let split = conversation.length
|
let split = conversation.length
|
||||||
let splitPrefix = ""
|
let splitPrefix = ""
|
||||||
@@ -191,21 +172,19 @@ export const buildPrompt = (input: { readonly previousSummary?: string; readonly
|
|||||||
...input.context,
|
...input.context,
|
||||||
].join("\n\n")
|
].join("\n\n")
|
||||||
|
|
||||||
const make = (dependencies: Dependencies) => {
|
export const make = (dependencies: Dependencies) => {
|
||||||
const config = settings(dependencies.config)
|
const config = settings(dependencies.config)
|
||||||
const compact = Effect.fn("SessionCompaction.compact")(function* (input: {
|
const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: Input) {
|
||||||
readonly sessionID: SessionSchema.ID
|
|
||||||
readonly model: Model
|
|
||||||
readonly reason: SessionMessage.Compaction["reason"]
|
|
||||||
readonly previousSummary?: string
|
|
||||||
readonly context: readonly string[]
|
|
||||||
readonly recent: string
|
|
||||||
readonly output?: number
|
|
||||||
}) {
|
|
||||||
const context = input.model.route.defaults.limits?.context
|
const context = input.model.route.defaults.limits?.context
|
||||||
if (context === undefined || context <= 0) return false
|
if (context === undefined || context <= 0) return false
|
||||||
const output = input.output ?? input.model.route.defaults.limits?.output ?? 0
|
const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0
|
||||||
const summaryPrompt = buildPrompt({ previousSummary: input.previousSummary, context: input.context })
|
const selected = select(input.entries, config.tokens)
|
||||||
|
const previousSummary = input.entries.find((entry) => entry.message.type === "compaction")?.message
|
||||||
|
if (!selected || (selected.head.length === 0 && previousSummary?.type !== "compaction")) return false
|
||||||
|
const summaryPrompt = buildPrompt({
|
||||||
|
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
|
||||||
|
context: [previousSummary?.type === "compaction" ? previousSummary.recent : "", selected.head].filter(Boolean),
|
||||||
|
})
|
||||||
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
|
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
|
||||||
if (Token.estimate(summaryPrompt) > context - summaryOutput) return false
|
if (Token.estimate(summaryPrompt) > context - summaryOutput) return false
|
||||||
const messageID = SessionMessage.ID.create()
|
const messageID = SessionMessage.ID.create()
|
||||||
@@ -213,7 +192,7 @@ const make = (dependencies: Dependencies) => {
|
|||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
messageID,
|
messageID,
|
||||||
timestamp: yield* DateTime.now,
|
timestamp: yield* DateTime.now,
|
||||||
reason: input.reason,
|
reason: "auto",
|
||||||
})
|
})
|
||||||
|
|
||||||
const chunks: string[] = []
|
const chunks: string[] = []
|
||||||
@@ -242,58 +221,17 @@ const make = (dependencies: Dependencies) => {
|
|||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
messageID,
|
messageID,
|
||||||
timestamp: yield* DateTime.now,
|
timestamp: yield* DateTime.now,
|
||||||
reason: input.reason,
|
reason: "auto",
|
||||||
text: summary,
|
text: summary,
|
||||||
recent: input.recent,
|
recent: selected.recent,
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: AutoInput) {
|
const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: Input) {
|
||||||
return yield* compactSelected({
|
if (!config.auto) return false
|
||||||
sessionID: input.sessionID,
|
|
||||||
messages: input.messages,
|
|
||||||
model: input.request.model,
|
|
||||||
reason: "auto",
|
|
||||||
force: false,
|
|
||||||
output: input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const compactSelected = Effect.fn("SessionCompaction.compactSelected")(function* (
|
|
||||||
input: CompactInput & {
|
|
||||||
readonly reason: SessionMessage.Compaction["reason"]
|
|
||||||
readonly force: boolean
|
|
||||||
readonly output?: number
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const context = input.model.route.defaults.limits?.context
|
const context = input.model.route.defaults.limits?.context
|
||||||
if (context === undefined || context <= 0) return false
|
if (context === undefined || context <= 0) return false
|
||||||
const selected = select(input.messages, config.tokens)
|
const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0
|
||||||
if (!selected) return false
|
|
||||||
const previousSummary = input.messages.find((message) => message.type === "compaction")
|
|
||||||
const hasHead = selected.head.length > 0
|
|
||||||
if (!hasHead && previousSummary?.type !== "compaction" && !input.force) return false
|
|
||||||
const forcedShortContext = input.force && !hasHead
|
|
||||||
const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : ""
|
|
||||||
return yield* compact({
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
model: input.model,
|
|
||||||
reason: input.reason,
|
|
||||||
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
|
|
||||||
context: (forcedShortContext ? [previousRecent, selected.recent] : [previousRecent, selected.head]).filter(
|
|
||||||
Boolean,
|
|
||||||
),
|
|
||||||
recent: forcedShortContext ? "" : selected.recent,
|
|
||||||
output: input.output,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) {
|
|
||||||
return yield* compactSelected({ ...input, reason: "manual", force: true })
|
|
||||||
})
|
|
||||||
const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: AutoInput) {
|
|
||||||
if (!config.auto) return false
|
|
||||||
const context = input.request.model.route.defaults.limits?.context
|
|
||||||
if (context === undefined || context <= 0) return false
|
|
||||||
const output = input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0
|
|
||||||
if (
|
if (
|
||||||
estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <=
|
estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <=
|
||||||
context - Math.max(output, config.buffer)
|
context - Math.max(output, config.buffer)
|
||||||
@@ -304,37 +242,5 @@ const make = (dependencies: Dependencies) => {
|
|||||||
return {
|
return {
|
||||||
compactIfNeeded,
|
compactIfNeeded,
|
||||||
compactAfterOverflow,
|
compactAfterOverflow,
|
||||||
compactManual,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const events = yield* EventV2Service.Service
|
|
||||||
const llm = yield* LLMClient.Service
|
|
||||||
const config = yield* ConfigV2.Service
|
|
||||||
const models = yield* SessionRunnerModel.Service
|
|
||||||
const compaction = make({ events, llm, config: yield* config.entries() })
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
compactIfNeeded: compaction.compactIfNeeded,
|
|
||||||
compactAfterOverflow: compaction.compactAfterOverflow,
|
|
||||||
compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) {
|
|
||||||
const model = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
if (!model) return false
|
|
||||||
return yield* compaction.compactManual({
|
|
||||||
sessionID: input.session.id,
|
|
||||||
messages: input.messages,
|
|
||||||
model,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const node = makeLocationNode({
|
|
||||||
service: Service,
|
|
||||||
layer,
|
|
||||||
deps: [EventV2Service.node, llmClient, ConfigV2.node, SessionRunnerModel.node],
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ export interface Interface {
|
|||||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
|
||||||
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
||||||
@@ -32,6 +30,5 @@ export const noopLayer = Layer.succeed(
|
|||||||
resume: () => Effect.void,
|
resume: () => Effect.void,
|
||||||
wake: () => Effect.void,
|
wake: () => Effect.void,
|
||||||
interrupt: () => Effect.void,
|
interrupt: () => Effect.void,
|
||||||
awaitIdle: () => Effect.void,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export const layer = Layer.effect(
|
|||||||
interrupt: coordinator.interrupt,
|
interrupt: coordinator.interrupt,
|
||||||
resume: coordinator.run,
|
resume: coordinator.run,
|
||||||
wake: coordinator.wake,
|
wake: coordinator.wake,
|
||||||
awaitIdle: coordinator.awaitIdle,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user