@akanjs/devkit 3.1.1-rc.9 → 3.1.2-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -0
- package/agentAssets.test.ts +65 -0
- package/agentAssets.ts +166 -0
- package/agentsIndex.ts +4 -4
- package/akanApp/akanApp.host.test.ts +12 -0
- package/akanApp/akanApp.host.ts +12 -0
- package/akanConfig/akanConfig.test.ts +105 -0
- package/akanConfig/akanConfig.ts +21 -1
- package/akanConfig/productionLockfile.test.ts +31 -0
- package/akanConfig/productionLockfile.ts +2 -0
- package/akanContext.ts +129 -4
- package/akanMcpContract.test.ts +28 -1
- package/akanMcpContract.ts +51 -3
- package/applicationBuildRunner.test.ts +53 -0
- package/applicationBuildRunner.ts +7 -4
- package/artifact/implicitRootLayout.ts +4 -2
- package/biome.base.json +4 -1
- package/cloud/globalConfig.test.ts +34 -0
- package/cloud/globalConfig.ts +2 -2
- package/codeAgent/agent/AkanCodeEnv.ts +26 -0
- package/codeAgent/agent/AkanCodeServices.ts +5 -2
- package/codeAgent/agent/CodeAgent.ts +11 -0
- package/codeAgent/agent/CodeAgentGate.ts +156 -10
- package/codeAgent/agent/CodeAgentProxy.ts +5 -4
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
- package/codeAgent/agent/SubagentPool.ts +5 -0
- package/codeAgent/agent/WorkspaceTrust.ts +200 -0
- package/codeAgent/agent/akanCodePaths.ts +8 -1
- package/codeAgent/agent/akanSystemPrompt.ts +5 -1
- package/codeAgent/agent/codeAgent.test.ts +41 -2
- package/codeAgent/agent/codeAgentContract.test.ts +119 -0
- package/codeAgent/agent/codeAgentHosts.ts +30 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
- package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
- package/codeAgent/agent/stringArg.ts +1 -1
- package/codeAgent/feedback/PreviewFeedback.ts +2 -2
- package/codeAgent/feedback/VerifyFeedback.ts +3 -4
- package/codeAgent/index.ts +2 -2
- package/codeAgent/skills/akan-agent/SKILL.md +43 -0
- package/codeAgent/skills/akan-code/SKILL.md +38 -11
- package/codeAgent/skills/akan-debug/SKILL.md +41 -6
- package/codeAgent/skills/akan-deps/SKILL.md +1 -2
- package/codeAgent/skills/akan-err/SKILL.md +2 -1
- package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
- package/codeAgent/skills/akan-page/SKILL.md +44 -0
- package/codeAgent/skills/akan-query/SKILL.md +39 -0
- package/codeAgent/skills/akan-runtime/SKILL.md +43 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
- package/codeAgent/skills/akan-transport/SKILL.md +40 -0
- package/codeAgent/skills/akan-validate/SKILL.md +13 -11
- package/codeAgent/tools/AkanCli.ts +2 -1
- package/codeAgent/tools/AkanCodePlugins.ts +5 -1
- package/codeAgent/tools/AkanEditScope.ts +8 -3
- package/codeAgent/tools/AkanToolPack.ts +4 -10
- package/codeAgent/tools/AkanVerifier.ts +13 -31
- package/codeAgent/tools/McpServerConfig.ts +6 -4
- package/codeAgent/tools/McpSignIn.ts +7 -7
- package/codeAgent/tools/McpTokenStore.ts +63 -16
- package/codeAgent/tools/McpToolPack.ts +8 -1
- package/codeAgent/tools/WebToolPack.ts +50 -6
- package/codeAgent/tools/akanCodePlugins.test.ts +130 -60
- package/codeAgent/tools/mcpOAuth.test.ts +50 -14
- package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
- package/commandDecorators/command.ts +4 -1
- package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
- package/commandDecorators/workspaceVersionCheck.ts +4 -1
- package/commentDensityScanner.test.ts +121 -0
- package/commentDensityScanner.ts +101 -0
- package/componentSizeScanner.test.ts +36 -0
- package/componentSizeScanner.ts +52 -0
- package/componentWriteChainScanner.test.ts +137 -0
- package/componentWriteChainScanner.ts +104 -0
- package/conventionSet.test.ts +73 -0
- package/conventionSet.ts +67 -0
- package/createTunnel.test.ts +200 -2
- package/createTunnel.ts +80 -6
- package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
- package/deprecatedCommentScanner.test.ts +54 -0
- package/deprecatedCommentScanner.ts +30 -0
- package/endpointReach.test.ts +58 -0
- package/endpointReach.ts +80 -0
- package/envSeed.test.ts +90 -0
- package/envSeed.ts +68 -0
- package/executors.test.ts +19 -5
- package/executors.ts +59 -4
- package/fileSys.test.ts +30 -2
- package/fileSys.ts +14 -0
- package/formSetterScanner.test.ts +44 -1
- package/formSetterScanner.ts +53 -8
- package/frontendBuild/csrArtifactBuilder.ts +7 -0
- package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
- package/frontendBuild/mcpAppViewBuilder.ts +7 -1
- package/frontendBuild/optionalPeers.test.ts +132 -0
- package/index.ts +1 -0
- package/integration/devStability.integration.test.ts +9 -5
- package/integration/ssrMemoryProbe.ts +6 -0
- package/kindBranchScanner.test.ts +89 -0
- package/kindBranchScanner.ts +158 -0
- package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
- package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
- package/lint/no-oversized-list-limit.grit +73 -0
- package/lint/no-unguarded-endpoint.grit +5 -5
- package/linter.test.ts +12 -0
- package/linter.ts +35 -12
- package/maintainabilityBaseline.test.ts +280 -0
- package/maintainabilityBaseline.ts +220 -0
- package/maintainabilityContract.test.ts +846 -0
- package/maintainabilityContract.ts +404 -0
- package/maintainabilityExceptionScanner.test.ts +90 -0
- package/maintainabilityExceptionScanner.ts +85 -0
- package/maintainabilityLimits.ts +60 -0
- package/maintainabilityReport.test.ts +130 -0
- package/maintainabilityReport.ts +153 -0
- package/mobile/desktopRelease.test.ts +1 -1
- package/mobile/desktopRelease.ts +3 -1
- package/mobile/desktopServerStage.test.ts +1 -0
- package/mobile/desktopServerStage.ts +3 -1
- package/mobile/nativeApp.test.ts +20 -5
- package/mobile/nativeApp.ts +4 -1
- package/mobile/nativeConfig.test.ts +209 -5
- package/mobile/nativeConfig.ts +127 -6
- package/mobile/nativePublicEnv.test.ts +2 -0
- package/mobile/nativePublicEnv.ts +13 -0
- package/package.json +4 -2
- package/platformTest/LinuxDockerTarget.ts +3 -0
- package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
- package/preview/PreviewView.ts +219 -0
- package/preview/index.ts +2 -0
- package/preview/previewView.test.ts +76 -0
- package/prompter.ts +7 -0
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/routeSourceValidator.test.ts +11 -2
- package/routeSourceValidator.ts +14 -2
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/shellOnlyEnv.test.ts +65 -0
- package/shellOnlyEnv.ts +54 -0
- package/signalMutationIndex.test.ts +15 -0
- package/signalMutationIndex.ts +23 -82
- package/sourceComments.test.ts +45 -0
- package/sourceComments.ts +70 -0
- package/sshHostKeys.ts +59 -0
- package/storeAccessScanner.test.ts +84 -0
- package/storeAccessScanner.ts +71 -0
- package/storeSource.ts +160 -0
- package/storeWriteScanner.test.ts +292 -0
- package/storeWriteScanner.ts +50 -0
- package/subspace.test.ts +10 -0
- package/subspaceClone.ts +3 -1
- package/subspaceConfig.ts +6 -0
- package/testOnlyExportScanner.test.ts +83 -0
- package/testOnlyExportScanner.ts +124 -0
- package/transforms/barrelImportsPlugin.ts +10 -4
- package/transforms/mcpAppPageTrimmer.test.ts +8 -2
- package/transforms/mcpAppPageTrimmer.ts +5 -20
- package/transforms/pureLazyMarker.ts +1 -1
- package/typeChecker.ts +32 -0
- package/typecheck/projectTypecheck.test.ts +18 -0
- package/typecheck/projectTypecheck.ts +29 -5
- package/typecheck/typecheck.proc.ts +6 -0
- package/typecheck/typecheckFailure.ts +13 -0
- package/types.ts +1 -0
- package/workflow/artifacts.ts +16 -2
- package/workflow/executor.ts +22 -7
- package/workflow/guardCatalog.test.ts +59 -0
- package/workflow/guardCatalog.ts +108 -0
- package/workflow/index.ts +4 -0
- package/workflow/interactionScaffold.ts +122 -0
- package/workflow/mutationScaffold.ts +533 -0
- package/workflow/mutationSource.test.ts +108 -0
- package/workflow/mutationSource.ts +219 -0
- package/workflow/plan.ts +82 -0
- package/workflow/render.ts +19 -4
- package/workflow/source.test.ts +185 -0
- package/workflow/source.ts +151 -24
- package/workflow/sourceAst.ts +10 -48
- package/workflow/types.ts +18 -1
- package/workspaceLayout.ts +2 -0
- package/writePath.test.ts +109 -0
- package/writePath.ts +219 -0
- package/writePlacementScanner.test.ts +77 -0
- package/writePlacementScanner.ts +50 -0
- package/writingActionIndex.test.ts +99 -0
- package/writingActionIndex.ts +63 -0
- package/codeAgent/feedback/PreviewView.ts +0 -95
- /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
|
@@ -43,8 +43,8 @@ Each result names the next call in `next.tool`. Follow it; do not improvise.
|
|
|
43
43
|
and each workflow's inputs.)
|
|
44
44
|
2. `apply_workflow({ planPath })` — writes the scaffold and syncs the generated barrels. Returns
|
|
45
45
|
`validationTarget`.
|
|
46
|
-
3. `run_validation({ runIdOrPlan: <validationTarget> })`, or
|
|
47
|
-
|
|
46
|
+
3. `run_validation({ runIdOrPlan: <validationTarget> })`, or run `akan verify` (`akan_verify` in akan code);
|
|
47
|
+
both are the whole chain in one process.
|
|
48
48
|
4. **Read each scaffolded file before editing it.** They are a few lines each and they extend base classes
|
|
49
49
|
that already generated the CRUD surface. Read the `akan-store` skill before touching a store or adding
|
|
50
50
|
signal endpoints, or you will redeclare `create<Model>` and hit an "already exists" typecheck error.
|
|
@@ -60,9 +60,19 @@ Each result names the next call in `next.tool`. Follow it; do not improvise.
|
|
|
60
60
|
- Hand-writing files is the fallback, used only when no workflow matches, or when apply reports
|
|
61
61
|
unsupported / no-op / failed diagnostics telling you to act manually. Say which case applied.
|
|
62
62
|
- Never hand-edit a generated file to "finish" a scaffold — use `repair_generated` or `akan sync <app>`.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
|
|
64
|
+
## No workflow tools? The CLI is the same chain
|
|
65
|
+
|
|
66
|
+
The tools come from `akan mcp` (`--mode plan` reads, `--mode apply` writes). When they are not connected, each
|
|
67
|
+
has a command that prints the same report:
|
|
68
|
+
|
|
69
|
+
| tool | command |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `list_workflows` / `explain_workflow` | `akan workflow list` / `akan workflow explain <name>` |
|
|
72
|
+
| `plan_workflow` | `akan workflow plan create-module --app <app> --module comment --format json --out <path>` |
|
|
73
|
+
| `apply_workflow` | `akan workflow apply <path> --format json` (`--dry-run` previews) |
|
|
74
|
+
| `run_validation` | `akan verify <app>` |
|
|
75
|
+
| `repair_generated` / `repair_imports` / `repair_module_shape` | `akan repair generated\|imports\|module-shape --app <app>` |
|
|
66
76
|
|
|
67
77
|
## Worked example — "an intro page, two detail pages, and a comment zone with a database"
|
|
68
78
|
|
|
@@ -73,4 +83,4 @@ Each result names the next call in `next.tool`. Follow it; do not improvise.
|
|
|
73
83
|
leave the store body empty unless you need extra state.
|
|
74
84
|
4. Write the pages and the comment UI against the generated actions — `st.do.createComment`,
|
|
75
85
|
`st.do.initCommentInPublic`, `st.use.commentListInPublic`.
|
|
76
|
-
5. `
|
|
86
|
+
5. `akan verify` until it prints `verify ok`.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: akan-transport
|
|
3
|
+
description: "Guard and tune endpoints on the wire: writing a Can<Verb><Model> guard, deadlines, cache, idempotency, rate limits, server-to-server calls, body types, bytes and HTTP verbs."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Akan transport
|
|
7
|
+
|
|
8
|
+
## Guards
|
|
9
|
+
|
|
10
|
+
- Every custom `query` / `mutation` / `pubsub` / `message` names its own `guards: [...]`; every `slice()` takes an
|
|
11
|
+
explicit guard map with `root: Admin`. `Public` belongs on a slice `get:`, and on a mutation only for an act that
|
|
12
|
+
must work signed out (sign-up, sign-in, token refresh, an OAuth route), which also declares a `rateLimit`.
|
|
13
|
+
- A resource guard is a `Can<Verb><Model>` class in the model library's `srvkit/guards.ts` that fails closed:
|
|
14
|
+
no resource named, or a load that throws, is `false` (after a `logger.warn`). Admin bypass goes first.
|
|
15
|
+
- Every guard declares `static scope`: `"account"` reads only the caller (`SignedIn`, `Admin`, roles), and
|
|
16
|
+
`"resource"` needs the call's arguments (every `Can<Verb><Model>`). Keep `static name = "User";` with a comment.
|
|
17
|
+
- Guards read `context.get<T>("account")`, serve HTTP and websocket alike, and stay side-effect free. The acting
|
|
18
|
+
user comes from `.with(Self)` / `.with(CurrentUserId)` / `.with(Me)`, the address from `.with(Ip)` — never a
|
|
19
|
+
client-supplied id. The service re-checks ownership: two independent gates.
|
|
20
|
+
|
|
21
|
+
## Endpoint options
|
|
22
|
+
|
|
23
|
+
- `{ timeout: <ms> }` is the deadline on both ends; undeclared, the client quits at 30 s and the handler is not
|
|
24
|
+
cancelled.
|
|
25
|
+
- `{ cache }` only on a `query` with no internal argument, capped at one day; `invalidate(endpoint, args?)` drops
|
|
26
|
+
it early through a plugged `EndpointCacheControl`.
|
|
27
|
+
- `{ rateLimit: { calls, windowMs?, by? } }` per endpoint, `option.setRateLimit` per app, `plug(RateLimit)` for a
|
|
28
|
+
business key.
|
|
29
|
+
- A mutation repeated under one `Idempotency-Key` header runs once and replays its first answer.
|
|
30
|
+
- `{ method: "PATCH" | "PUT" | "DELETE" }` moves a mutation off `POST` — only when a foreign protocol forces it.
|
|
31
|
+
- `errors: { 409: ["ticket.error.locked"] }` names the errors an endpoint throws on purpose.
|
|
32
|
+
|
|
33
|
+
## Bodies and other servers
|
|
34
|
+
|
|
35
|
+
- `.body()` / `.param()` take scalars, model refs or `enumOf(...)`; `Int` / `Float`, never `Number`; bytes are
|
|
36
|
+
`Binary`, never `Any`; `Upload` only in a `fileUpload: true` mutation.
|
|
37
|
+
- Server to server, `fetch.clone({ origin })` opens no socket without `connect: true`, retries through `retry`,
|
|
38
|
+
and rethrows a remote `Err` as it is — never wrap or re-key it.
|
|
39
|
+
|
|
40
|
+
Full detail: `get_guideline` with `transportRule`, or `mcpRule` for what a guard's scope does to an MCP listing.
|
|
@@ -7,17 +7,19 @@ description: "Run the akan check loop after editing and fix each failure class t
|
|
|
7
7
|
|
|
8
8
|
## One call does the whole chain
|
|
9
9
|
|
|
10
|
-
`
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- `akan
|
|
19
|
-
- `akan
|
|
20
|
-
- `akan build
|
|
10
|
+
`akan verify` (the `akan_verify` tool in akan code) syncs, lints with fixes, typechecks, fast-builds each
|
|
11
|
+
changed app, and runs the SSR check for a `.tsx` change — over everything the working tree has changed, in one
|
|
12
|
+
process. It prints one line when it passes and one line per problem when it fails. **Run it after editing,
|
|
13
|
+
before you say you are done.** In akan code it also runs itself at the end of a turn that changed source.
|
|
14
|
+
|
|
15
|
+
Never run `akan lint`, `akan typecheck` and `akan build` in turn: that is the same work three times over, at
|
|
16
|
+
three times the output. Narrow the one command instead:
|
|
17
|
+
|
|
18
|
+
- `akan verify <app-or-lib-or-pkg>` — that target whole, changed or not.
|
|
19
|
+
- `akan verify --lint-only` — the quick pass between edits.
|
|
20
|
+
- `akan verify --no-build` — leave the app build out.
|
|
21
|
+
- `akan verify --max 0` — every line, when the report ends in `+N more`.
|
|
22
|
+
- `fixed N files` in the header means lint rewrote them on disk: re-read before editing them again.
|
|
21
23
|
|
|
22
24
|
Raw `tsc` misses the generated barrels and reports false errors. Do not reach for it.
|
|
23
25
|
|
|
@@ -24,7 +24,8 @@ export class AkanCli {
|
|
|
24
24
|
cwd: this.#cwd,
|
|
25
25
|
stdout: "pipe",
|
|
26
26
|
stderr: "pipe",
|
|
27
|
-
|
|
27
|
+
//? A workspace .env's AKAN_PUBLIC_LOG_LEVEL=verbose reaches the child too, and buried its error under 90 lines.
|
|
28
|
+
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1", AKAN_LOG_STDOUT_LEVEL: "warn" },
|
|
28
29
|
});
|
|
29
30
|
const timer = setTimeout(() => proc.kill(), timeoutMs);
|
|
30
31
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { CodeAgentMcpStatus, CodeAgentProfile, CodeAgentQuestion, CodeAgentSubagent } from "akanjs/common";
|
|
3
3
|
import type { Workspace } from "../../commandDecorators";
|
|
4
|
+
import { PreviewView } from "../../preview/PreviewView";
|
|
5
|
+
import type { CodeAgentModelRef } from "../agent/akanCodeModel";
|
|
4
6
|
import type { CodeMailbox } from "../agent/CodeMailbox";
|
|
5
7
|
import { SubagentPool, type SubagentSpend } from "../agent/SubagentPool";
|
|
6
8
|
import { DevLogFeedback } from "../feedback/DevLogFeedback";
|
|
7
9
|
import { PreviewFeedback } from "../feedback/PreviewFeedback";
|
|
8
|
-
import { PreviewView } from "../feedback/PreviewView";
|
|
9
10
|
import { TurnFeedback, type TurnFeedbackSource } from "../feedback/TurnFeedback";
|
|
10
11
|
import { VerifyFeedback } from "../feedback/VerifyFeedback";
|
|
11
12
|
import { AkanToolPack } from "./AkanToolPack";
|
|
@@ -23,6 +24,8 @@ export interface AkanCodePluginsOptions {
|
|
|
23
24
|
/** Apps whose dev server should be watched, and whose page should be probed. */
|
|
24
25
|
apps: string[];
|
|
25
26
|
canSeeImages: boolean;
|
|
27
|
+
/** The owning agent's model as it is now; a `task` child runs on it. */
|
|
28
|
+
model?: () => CodeAgentModelRef | undefined;
|
|
26
29
|
deepseek?: DeepSeekWeb | undefined;
|
|
27
30
|
/** How many `task` levels deep the owning agent is. */
|
|
28
31
|
depth: number;
|
|
@@ -92,6 +95,7 @@ export class AkanCodePlugins {
|
|
|
92
95
|
workspace: options.workspace,
|
|
93
96
|
cwd: options.cwd,
|
|
94
97
|
parent: options.profile,
|
|
98
|
+
...(options.model ? { model: options.model } : {}),
|
|
95
99
|
depth: options.depth,
|
|
96
100
|
...(options.subagentSpend ? { spend: options.subagentSpend } : {}),
|
|
97
101
|
...(options.onNotice ? { onNotice: options.onNotice } : {}),
|
|
@@ -2,12 +2,13 @@ export interface AkanEditScopeResult {
|
|
|
2
2
|
paths: string[];
|
|
3
3
|
apps: string[];
|
|
4
4
|
libs: string[];
|
|
5
|
+
pkgs: string[];
|
|
5
6
|
/** A created, deleted or renamed file under `apps/**` or `libs/**` — the only kind a barrel has to catch up with. */
|
|
6
7
|
needsSync: boolean;
|
|
7
8
|
touchesTsx: boolean;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
const empty: AkanEditScopeResult = { paths: [], apps: [], libs: [], needsSync: false, touchesTsx: false };
|
|
11
|
+
const empty: AkanEditScopeResult = { paths: [], apps: [], libs: [], pkgs: [], needsSync: false, touchesTsx: false };
|
|
11
12
|
|
|
12
13
|
// Read from git, not tool events: an edit made through `bash` (a heredoc, sed, a codemod) produces no write tool call.
|
|
13
14
|
export class AkanEditScope {
|
|
@@ -21,6 +22,7 @@ export class AkanEditScope {
|
|
|
21
22
|
paths,
|
|
22
23
|
apps: AkanEditScope.#targets(paths, "apps"),
|
|
23
24
|
libs: AkanEditScope.#targets(paths, "libs"),
|
|
25
|
+
pkgs: AkanEditScope.#targets(paths, "pkgs"),
|
|
24
26
|
needsSync: entries.some((entry) => /[ARD?]/.test(entry.code) && /^(apps|libs)\//.test(entry.file)),
|
|
25
27
|
touchesTsx: paths.some((file) => file.endsWith(".tsx")),
|
|
26
28
|
};
|
|
@@ -38,10 +40,13 @@ export class AkanEditScope {
|
|
|
38
40
|
return new Set(text.split("\n").filter((line) => line.trim().length > 3));
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
static #targets(paths: string[], root: "apps" | "libs") {
|
|
43
|
+
static #targets(paths: string[], root: "apps" | "libs" | "pkgs") {
|
|
42
44
|
const names = paths
|
|
43
45
|
.filter((file) => file.startsWith(`${root}/`))
|
|
44
|
-
.map((file) =>
|
|
46
|
+
.map((file) => {
|
|
47
|
+
const [, first = "", second = ""] = file.split("/");
|
|
48
|
+
return first.startsWith("@") && second ? `${first}/${second}` : first;
|
|
49
|
+
})
|
|
45
50
|
.filter(Boolean);
|
|
46
51
|
return [...new Set(names)];
|
|
47
52
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import type { Workspace } from "../../commandDecorators";
|
|
4
|
-
import { AkanEditScope } from "./AkanEditScope";
|
|
5
4
|
import { AkanVerifier } from "./AkanVerifier";
|
|
6
5
|
|
|
7
6
|
export interface AkanToolPackOptions {
|
|
@@ -66,17 +65,12 @@ export class AkanToolPack {
|
|
|
66
65
|
name: AkanToolPack.verifyToolName,
|
|
67
66
|
label: "akan verify",
|
|
68
67
|
description:
|
|
69
|
-
"Run the
|
|
70
|
-
promptSnippet: "akan_verify:
|
|
68
|
+
"Run `akan verify` over everything the working tree has changed: sync, lint (with fixes), typecheck, a fast build of each changed app, and the SSR check for .tsx changes, in one call. It prints one line when it passes and one line per problem when it fails. Call this after editing, before you finish; never run lint, typecheck or build separately.",
|
|
69
|
+
promptSnippet: "akan_verify: sync, lint, typecheck and build what the working tree changed, in one call",
|
|
71
70
|
parameters: Type.Object({}),
|
|
72
71
|
execute: async () => {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
content: [{ type: "text", text: AkanVerifier.summarize(report) }],
|
|
77
|
-
details: undefined,
|
|
78
|
-
isError: !report.ok,
|
|
79
|
-
};
|
|
72
|
+
const report = await verifier.verify();
|
|
73
|
+
return { content: [{ type: "text", text: report.text }], details: undefined, isError: !report.ok };
|
|
80
74
|
},
|
|
81
75
|
});
|
|
82
76
|
}
|
|
@@ -1,48 +1,30 @@
|
|
|
1
1
|
import { AkanCli, type AkanCliResult } from "./AkanCli";
|
|
2
|
-
import type { AkanEditScopeResult } from "./AkanEditScope";
|
|
3
2
|
|
|
4
3
|
export interface AkanVerifyReport {
|
|
5
4
|
ok: boolean;
|
|
6
|
-
|
|
7
|
-
scope: AkanEditScopeResult;
|
|
5
|
+
text: string;
|
|
8
6
|
}
|
|
9
7
|
|
|
10
8
|
export class AkanVerifier {
|
|
9
|
+
static readonly tailLines = 20;
|
|
11
10
|
readonly #cli: AkanCli;
|
|
12
11
|
|
|
13
12
|
constructor(cwd: string) {
|
|
14
13
|
this.#cli = new AkanCli(cwd);
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
async verify(
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
if (scope.needsSync) for (const target of targets) steps.push(await this.#cli.run(["sync", target]));
|
|
22
|
-
for (const target of targets) steps.push(await this.#cli.run(["lint", target]));
|
|
23
|
-
for (const app of scope.apps) steps.push(await this.#cli.run(["typecheck", app]));
|
|
24
|
-
if (scope.touchesTsx) steps.push(await this.#cli.run(["quality", "ssr"]));
|
|
25
|
-
return { ok: steps.every((step) => step.ok), steps, scope };
|
|
16
|
+
async verify(paths: string[] = []): Promise<AkanVerifyReport> {
|
|
17
|
+
const args = ["verify", ...(paths.length ? ["--files", paths.join(",")] : [])];
|
|
18
|
+
const result = await this.#cli.run(args, { timeoutMs: 600_000 });
|
|
19
|
+
return { ok: result.ok, text: AkanVerifier.summarize(result) };
|
|
26
20
|
}
|
|
27
21
|
|
|
28
|
-
|
|
29
|
-
static summarize(
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return [
|
|
36
|
-
`Verification failed: ${failed.map((step) => step.command).join(", ")}.`,
|
|
37
|
-
passed.length ? `Passed: ${passed.join(", ")}.` : "",
|
|
38
|
-
...blocks,
|
|
39
|
-
]
|
|
40
|
-
.filter(Boolean)
|
|
41
|
-
.join("\n\n");
|
|
22
|
+
//* `akan verify` already prints the agent-sized report; only a crash before it prints needs trimming.
|
|
23
|
+
static summarize(result: AkanCliResult) {
|
|
24
|
+
if (result.output.startsWith("verify ")) return result.output;
|
|
25
|
+
const lines = result.output.split("\n");
|
|
26
|
+
const tail =
|
|
27
|
+
lines.length <= AkanVerifier.tailLines ? result.output : `…\n${lines.slice(-AkanVerifier.tailLines).join("\n")}`;
|
|
28
|
+
return `${result.command} exited ${result.exitCode} before reporting:\n${tail}`;
|
|
42
29
|
}
|
|
43
30
|
}
|
|
44
|
-
|
|
45
|
-
const tail = (text: string, lines: number) => {
|
|
46
|
-
const all = text.split("\n");
|
|
47
|
-
return all.length <= lines ? text : `…\n${all.slice(-lines).join("\n")}`;
|
|
48
|
-
};
|
|
@@ -42,16 +42,18 @@ export class McpServerConfig {
|
|
|
42
42
|
];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
static read(workspaceRoot: string): CodeAgentMcpServerRef[] {
|
|
46
|
-
return McpServerConfig.declared(workspaceRoot)
|
|
45
|
+
static read(workspaceRoot: string, scopes?: McpServerScope[]): CodeAgentMcpServerRef[] {
|
|
46
|
+
return McpServerConfig.declared(workspaceRoot, scopes)
|
|
47
47
|
.filter((entry) => !entry.disabled)
|
|
48
48
|
.map((entry) => entry.ref);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/** Every declared server including the disabled ones, which is what a listing has to show. */
|
|
52
|
-
static declared(workspaceRoot: string): McpDeclaredServer[] {
|
|
52
|
+
static declared(workspaceRoot: string, scopes?: McpServerScope[]): McpDeclaredServer[] {
|
|
53
53
|
const byName = new Map<string, McpDeclaredServer>();
|
|
54
|
-
for (const { scope } of McpServerConfig.files(workspaceRoot)
|
|
54
|
+
for (const { scope } of McpServerConfig.files(workspaceRoot).filter(
|
|
55
|
+
(file) => !scopes || scopes.includes(file.scope),
|
|
56
|
+
))
|
|
55
57
|
for (const [name, entry] of Object.entries(McpServerConfig.#parse(workspaceRoot, scope).map))
|
|
56
58
|
byName.set(name, { ref: McpServerConfig.#refOf(name, entry), disabled: !!entry.disabled, scope });
|
|
57
59
|
return [...byName.values()];
|
|
@@ -16,7 +16,7 @@ export class McpSignIn {
|
|
|
16
16
|
|
|
17
17
|
/** The token to connect with, or undefined when none is had without asking; a failed refresh is not a throw. */
|
|
18
18
|
static async token(ref: CodeAgentMcpServerRef, onNotice?: (message: string) => void) {
|
|
19
|
-
const stored = McpTokenStore.read(ref
|
|
19
|
+
const stored = McpTokenStore.read(ref);
|
|
20
20
|
if (!stored) return undefined;
|
|
21
21
|
if (!McpTokenStore.isExpired(stored)) return stored.accessToken;
|
|
22
22
|
return await McpSignIn.#refreshOnce(ref, stored, onNotice);
|
|
@@ -24,14 +24,14 @@ export class McpSignIn {
|
|
|
24
24
|
|
|
25
25
|
/** After a server answered `refused` with 401: a token another session stored since, else one refresh. */
|
|
26
26
|
static async retryToken(ref: CodeAgentMcpServerRef, refused: string, onNotice?: (message: string) => void) {
|
|
27
|
-
const stored = McpTokenStore.read(ref
|
|
27
|
+
const stored = McpTokenStore.read(ref);
|
|
28
28
|
if (!stored) return undefined;
|
|
29
29
|
if (stored.accessToken !== refused && !McpTokenStore.isExpired(stored)) return stored.accessToken;
|
|
30
30
|
return await McpSignIn.#refreshOnce(ref, stored, onNotice);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
static async #refreshOnce(ref: CodeAgentMcpServerRef, stored: McpStoredAuth, onNotice?: (message: string) => void) {
|
|
34
|
-
const key = `${
|
|
34
|
+
const key = `${stored.issuer}\n${stored.refreshToken}`;
|
|
35
35
|
let pending = McpSignIn.#refreshing.get(key);
|
|
36
36
|
if (!pending) {
|
|
37
37
|
pending = McpSignIn.#refreshed(ref, stored, onNotice).finally(() => McpSignIn.#refreshing.delete(key));
|
|
@@ -45,7 +45,7 @@ export class McpSignIn {
|
|
|
45
45
|
try {
|
|
46
46
|
return await ConfigLock.run(McpTokenStore.lockFile(), async () => {
|
|
47
47
|
// Another `akan code` may have refreshed while this one waited: take its token, never its spent refresh token.
|
|
48
|
-
const current = McpTokenStore.read(ref
|
|
48
|
+
const current = McpTokenStore.read(ref);
|
|
49
49
|
if (!current) return undefined;
|
|
50
50
|
if (current.accessToken !== stored.accessToken && !McpTokenStore.isExpired(current)) return current.accessToken;
|
|
51
51
|
const server: McpOAuthServer = {
|
|
@@ -58,7 +58,7 @@ export class McpSignIn {
|
|
|
58
58
|
// `expires_in` is optional (RFC 6749 §5.1): the spent token's expiry must not outlive it on the new one.
|
|
59
59
|
const { expiresAt: _spent, ...grant } = current;
|
|
60
60
|
// A server that rotates refresh tokens invalidates the old one, so keeping it would sign us out.
|
|
61
|
-
McpTokenStore.write(ref
|
|
61
|
+
McpTokenStore.write(ref, { ...grant, ...tokens });
|
|
62
62
|
return tokens.accessToken;
|
|
63
63
|
});
|
|
64
64
|
} catch (error) {
|
|
@@ -72,7 +72,7 @@ export class McpSignIn {
|
|
|
72
72
|
if (!ref.url) throw new Error(`"${ref.name}" is a stdio server: give it its credential through "env".`);
|
|
73
73
|
const challenge = await McpSignIn.challenge(ref);
|
|
74
74
|
const server = await McpOAuth.discover(ref.url, challenge);
|
|
75
|
-
const stored = McpTokenStore.read(ref
|
|
75
|
+
const stored = McpTokenStore.read(ref);
|
|
76
76
|
const reusable = stored && stored.issuer === server.issuer && !ref.oauth?.clientId ? stored : undefined;
|
|
77
77
|
const loopback = McpOAuthLoopback.bind(McpSignIn.#portOf(reusable?.redirectUri));
|
|
78
78
|
try {
|
|
@@ -98,7 +98,7 @@ export class McpSignIn {
|
|
|
98
98
|
redirectUri,
|
|
99
99
|
...tokens,
|
|
100
100
|
};
|
|
101
|
-
McpTokenStore.write(ref
|
|
101
|
+
McpTokenStore.write(ref, auth);
|
|
102
102
|
return auth;
|
|
103
103
|
} finally {
|
|
104
104
|
loopback.close();
|
|
@@ -20,7 +20,18 @@ export interface McpStoredAuth {
|
|
|
20
20
|
scope?: string;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export interface McpTokenOwner {
|
|
24
|
+
name: string;
|
|
25
|
+
url?: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface McpTokenEntry extends McpStoredAuth {
|
|
29
|
+
name: string;
|
|
30
|
+
origin: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
// Home directory only, mode 0600: `.akan/code/mcp.json` is shared with the editors and routinely committed.
|
|
34
|
+
// Keyed by the server's origin, not its name: a workspace `mcp.json` can give a known name any url.
|
|
24
35
|
export class McpTokenStore {
|
|
25
36
|
static file() {
|
|
26
37
|
return akanCodePaths.mcpAuthFile();
|
|
@@ -30,24 +41,37 @@ export class McpTokenStore {
|
|
|
30
41
|
return `${McpTokenStore.file()}.lock`;
|
|
31
42
|
}
|
|
32
43
|
|
|
33
|
-
static read(
|
|
34
|
-
|
|
44
|
+
static read(owner: McpTokenOwner): McpStoredAuth | undefined {
|
|
45
|
+
const origin = McpTokenStore.#originOf(owner.url);
|
|
46
|
+
if (!origin) return undefined;
|
|
47
|
+
const here = McpTokenStore.#all().filter((entry) => entry.origin === origin);
|
|
48
|
+
const entry = here.find((candidate) => candidate.name === owner.name) ?? here[0];
|
|
49
|
+
if (!entry) return undefined;
|
|
50
|
+
const { name: _name, origin: _origin, ...auth } = entry;
|
|
51
|
+
return auth;
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
static names() {
|
|
38
|
-
return
|
|
55
|
+
return [...new Set(McpTokenStore.#all().map((entry) => entry.name))];
|
|
39
56
|
}
|
|
40
57
|
|
|
41
58
|
/** Re-reads right before writing, or two concurrent sign-ins each drop the other's token. */
|
|
42
|
-
static write(
|
|
43
|
-
|
|
59
|
+
static write(owner: McpTokenOwner, auth: McpStoredAuth) {
|
|
60
|
+
const origin = McpTokenStore.#originOf(owner.url);
|
|
61
|
+
if (!origin) throw new Error(`"${owner.name}" has no http(s) url to bind its token to.`);
|
|
62
|
+
const kept = McpTokenStore.#all().filter(
|
|
63
|
+
(entry) => entry.origin !== origin || (entry.issuer !== auth.issuer && entry.name !== owner.name),
|
|
64
|
+
);
|
|
65
|
+
McpTokenStore.#save([...kept, { ...auth, name: owner.name, origin }]);
|
|
44
66
|
}
|
|
45
67
|
|
|
46
|
-
|
|
68
|
+
//? Everything `read` could hand this server goes: its origin's tokens under any name, and its name's at any origin.
|
|
69
|
+
static clear(owner: McpTokenOwner) {
|
|
70
|
+
const origin = McpTokenStore.#originOf(owner.url);
|
|
47
71
|
const all = McpTokenStore.#all();
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
McpTokenStore.#save(
|
|
72
|
+
const kept = all.filter((entry) => entry.name !== owner.name && entry.origin !== origin);
|
|
73
|
+
if (kept.length === all.length) return false;
|
|
74
|
+
McpTokenStore.#save(kept);
|
|
51
75
|
return true;
|
|
52
76
|
}
|
|
53
77
|
|
|
@@ -58,22 +82,45 @@ export class McpTokenStore {
|
|
|
58
82
|
/** A token is treated as spent this long before it actually is, so a call never races its own expiry. */
|
|
59
83
|
static readonly skewMs = 60_000;
|
|
60
84
|
|
|
61
|
-
static #
|
|
85
|
+
static #originOf(url: string | undefined) {
|
|
86
|
+
if (!url) return undefined;
|
|
87
|
+
try {
|
|
88
|
+
const parsed = new URL(url);
|
|
89
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : undefined;
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static #keyOf(entry: McpTokenEntry) {
|
|
96
|
+
return `${entry.origin} ${entry.issuer}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
static #all(): McpTokenEntry[] {
|
|
62
100
|
const file = McpTokenStore.file();
|
|
63
|
-
if (!existsSync(file)) return
|
|
101
|
+
if (!existsSync(file)) return [];
|
|
64
102
|
try {
|
|
65
|
-
const parsed = JSON.parse(readFileSync(file, "utf8")) as {
|
|
66
|
-
|
|
103
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as {
|
|
104
|
+
tokens?: Record<string, McpTokenEntry>;
|
|
105
|
+
servers?: Record<string, McpStoredAuth>;
|
|
106
|
+
};
|
|
107
|
+
if (parsed.tokens) return Object.values(parsed.tokens);
|
|
108
|
+
// An older build keyed by name alone; its resource is the url the token was minted for, so it keeps that origin.
|
|
109
|
+
return Object.entries(parsed.servers ?? {}).flatMap(([name, auth]) => {
|
|
110
|
+
const origin = McpTokenStore.#originOf(auth.resource);
|
|
111
|
+
return origin ? [{ ...auth, name, origin }] : [];
|
|
112
|
+
});
|
|
67
113
|
} catch {
|
|
68
|
-
return
|
|
114
|
+
return [];
|
|
69
115
|
}
|
|
70
116
|
}
|
|
71
117
|
|
|
72
|
-
static #save(
|
|
118
|
+
static #save(entries: McpTokenEntry[]) {
|
|
73
119
|
const file = McpTokenStore.file();
|
|
74
120
|
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
121
|
+
const tokens = Object.fromEntries(entries.map((entry) => [McpTokenStore.#keyOf(entry), entry]));
|
|
75
122
|
const temp = `${file}.${process.pid}.tmp`;
|
|
76
|
-
writeFileSync(temp, `${JSON.stringify({
|
|
123
|
+
writeFileSync(temp, `${JSON.stringify({ tokens }, null, 2)}\n`, { mode: 0o600 });
|
|
77
124
|
renameSync(temp, file);
|
|
78
125
|
// `rename` keeps the temp file's mode, but an existing target written by an older build may be looser.
|
|
79
126
|
chmodSync(file, 0o600);
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
codeAgentOutputChars,
|
|
8
8
|
} from "akanjs/common";
|
|
9
9
|
import { Type } from "typebox";
|
|
10
|
+
import { WorkspaceTrust } from "../agent/WorkspaceTrust";
|
|
10
11
|
import { McpClient, type McpToolInfo, McpUnauthorized } from "./McpClient";
|
|
11
12
|
import { McpServerConfig } from "./McpServerConfig";
|
|
12
13
|
import { McpSignIn } from "./McpSignIn";
|
|
@@ -194,6 +195,12 @@ export class McpToolPack {
|
|
|
194
195
|
static #refs(options: McpToolPackOptions): CodeAgentMcpServerRef[] {
|
|
195
196
|
if (options.profile.tools.mcp === "off") return [];
|
|
196
197
|
const declared = Array.isArray(options.profile.tools.mcp) ? options.profile.tools.mcp : [];
|
|
197
|
-
|
|
198
|
+
if (WorkspaceTrust.isTrusted(options.workspaceRoot, "mcp"))
|
|
199
|
+
return [...McpServerConfig.read(options.workspaceRoot), ...declared];
|
|
200
|
+
const skipped = McpServerConfig.read(options.workspaceRoot, ["workspace"]).map((ref) => ref.name);
|
|
201
|
+
options.onNotice?.(
|
|
202
|
+
`Not started, because this repo's MCP servers are not approved: ${skipped.join(", ")}. Start akan code in a terminal to approve them, or pass --trust-workspace.`,
|
|
203
|
+
);
|
|
204
|
+
return [...McpServerConfig.read(options.workspaceRoot, ["global"]), ...declared];
|
|
198
205
|
}
|
|
199
206
|
}
|
|
@@ -3,6 +3,7 @@ import { isIP } from "node:net";
|
|
|
3
3
|
import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { type CodeAgentProfile, codeAgentClip } from "akanjs/common";
|
|
5
5
|
import { Type } from "typebox";
|
|
6
|
+
import { codeAgentHostListed, codeAgentHostOf, codeAgentIsLoopback } from "../agent/codeAgentHosts";
|
|
6
7
|
import { DeepSeekWeb } from "./DeepSeekWeb";
|
|
7
8
|
|
|
8
9
|
export interface WebToolPackOptions {
|
|
@@ -34,6 +35,7 @@ interface WebPage {
|
|
|
34
35
|
export class WebToolPack {
|
|
35
36
|
static readonly pageChars = 10_000;
|
|
36
37
|
static readonly maxResults = 10;
|
|
38
|
+
static readonly maxRedirects = 5;
|
|
37
39
|
static readonly #rememberedPages = 16;
|
|
38
40
|
readonly #options: WebToolPackOptions;
|
|
39
41
|
// A continuation reads the snapshot its first page came from; a fresh read always refetches, since a local dev
|
|
@@ -164,8 +166,10 @@ export class WebToolPack {
|
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
async #read(url: string, signal?: AbortSignal): Promise<WebPage> {
|
|
169
|
+
const within = this.#boundOf(new URL(url).hostname);
|
|
167
170
|
const deepseek = this.#options.deepseek;
|
|
168
|
-
|
|
171
|
+
// DeepSeek follows a redirect on its own servers, where no host list reaches it.
|
|
172
|
+
if (deepseek && !within && !(await this.#isLocal(new URL(url).hostname))) {
|
|
169
173
|
try {
|
|
170
174
|
const page = await deepseek.fetch(url, signal);
|
|
171
175
|
return { status: 200, ok: true, title: page.title, text: page.content };
|
|
@@ -173,11 +177,51 @@ export class WebToolPack {
|
|
|
173
177
|
if (signal?.aborted) throw error;
|
|
174
178
|
}
|
|
175
179
|
}
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
redirect: "follow"
|
|
179
|
-
|
|
180
|
-
|
|
180
|
+
const timeout = signal ? AbortSignal.any([signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000);
|
|
181
|
+
if (!within) {
|
|
182
|
+
const response = await fetch(url, { signal: timeout, redirect: "follow" });
|
|
183
|
+
return {
|
|
184
|
+
status: response.status,
|
|
185
|
+
ok: response.ok,
|
|
186
|
+
title: "",
|
|
187
|
+
text: WebToolPack.#readable(await response.text()),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return await WebToolPack.#readWithin(url, within, timeout);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#boundOf(hostname: string): ((host: string) => boolean) | undefined {
|
|
194
|
+
const { allowHosts, askOutside } = this.#options.profile.network;
|
|
195
|
+
if (allowHosts?.length) return (host) => allowHosts.includes(host);
|
|
196
|
+
const quiet = (host: string) => codeAgentIsLoopback(host) || codeAgentHostListed(host, askOutside ?? []);
|
|
197
|
+
return askOutside && quiet(hostname) ? quiet : undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
static async #readWithin(url: string, within: (host: string) => boolean, signal: AbortSignal): Promise<WebPage> {
|
|
201
|
+
let current = url;
|
|
202
|
+
for (let hop = 0; hop <= WebToolPack.maxRedirects; hop += 1) {
|
|
203
|
+
const response = await fetch(current, { signal, redirect: "manual" });
|
|
204
|
+
const location = response.headers.get("location");
|
|
205
|
+
if (response.status < 300 || response.status >= 400 || !location)
|
|
206
|
+
return {
|
|
207
|
+
status: response.status,
|
|
208
|
+
ok: response.ok,
|
|
209
|
+
title: "",
|
|
210
|
+
text: WebToolPack.#readable(await response.text()),
|
|
211
|
+
};
|
|
212
|
+
await response.body?.cancel();
|
|
213
|
+
const next = new URL(location, current).href;
|
|
214
|
+
const host = codeAgentHostOf(next);
|
|
215
|
+
if (!host || !within(host))
|
|
216
|
+
return {
|
|
217
|
+
status: response.status,
|
|
218
|
+
ok: false,
|
|
219
|
+
title: "",
|
|
220
|
+
text: `Redirected to ${next}, which this profile does not reach without asking. web_fetch that URL to follow it.`,
|
|
221
|
+
};
|
|
222
|
+
current = next;
|
|
223
|
+
}
|
|
224
|
+
return { status: 310, ok: false, title: "", text: `More than ${WebToolPack.maxRedirects} redirects.` };
|
|
181
225
|
}
|
|
182
226
|
|
|
183
227
|
#remember(url: string, page: WebPage) {
|