@akanjs/devkit 3.0.0-beta.17 → 3.0.0-beta.18
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/abstractDoc.ts +1 -3
- package/applicationBuildRunner.ts +4 -3
- package/artifact/implicitRootLayout.test.ts +6 -11
- package/artifact/implicitRootLayout.ts +0 -15
- package/biome.base.json +6 -1
- package/capacitorApp.test.ts +26 -0
- package/capacitorApp.ts +25 -4
- package/codeAgent/agent/AkanCodeServices.ts +16 -9
- package/codeAgent/agent/CodeAgent.ts +62 -13
- package/codeAgent/agent/akanCodePaths.ts +23 -3
- package/codeAgent/agent/akanSystemPrompt.ts +5 -0
- package/codeAgent/skills/akan-code/SKILL.md +122 -0
- package/codeAgent/skills/akan-module/SKILL.md +2 -2
- package/codeAgent/skills/akan-store/SKILL.md +6 -2
- package/codeAgent/tools/AkanCodePlugins.ts +19 -3
- package/codeAgent/tools/AskToolPack.ts +111 -0
- package/codeAgent/tools/McpClient.ts +51 -5
- package/codeAgent/tools/McpOAuth.ts +268 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +77 -0
- package/codeAgent/tools/McpServerConfig.ts +69 -34
- package/codeAgent/tools/McpSignIn.ts +161 -0
- package/codeAgent/tools/McpTokenStore.ts +92 -0
- package/codeAgent/tools/McpToolPack.ts +16 -3
- package/codeAgent/tools/askToolPack.test.ts +119 -0
- package/codeAgent/tools/mcpOAuth.test.ts +320 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +106 -12
- package/commandDecorators/command.ts +2 -0
- package/commandDecorators/commandDecorators.test.ts +1 -1
- package/commandDecorators/helpFormatter.ts +2 -1
- package/executors.test.ts +10 -46
- package/index.ts +0 -1
- package/package.json +2 -2
- package/prompter.ts +0 -7
- package/qualityScanner.test.ts +3 -2
- package/qualityScanner.ts +1 -1
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +0 -12
- package/scanInfo.ts +5 -2
- package/workflow/executor.ts +52 -0
- package/workflow/source.ts +19 -13
- package/guideline.ts +0 -19
package/abstractDoc.ts
CHANGED
|
@@ -5,10 +5,8 @@ export type AbstractKind = "domain" | "service" | "scalar" | "other";
|
|
|
5
5
|
/** One `*.abstract.md` file: the agent-facing summary a module carries next to its source. */
|
|
6
6
|
export class AbstractDoc {
|
|
7
7
|
static readonly suffix = ".abstract.md";
|
|
8
|
-
/** `akan quality` warns above this
|
|
8
|
+
/** `akan quality` warns above this. */
|
|
9
9
|
static readonly maxLines = 300;
|
|
10
|
-
/** `akan compact` floor, and the line budget it compacts down to, so a compacted file is never a candidate again. */
|
|
11
|
-
static readonly compactMinLines = 40;
|
|
12
10
|
// Abstracts only live in facet folders; globbing the sys root instead would walk node_modules.
|
|
13
11
|
static readonly #facetRoots = "{lib,ui,webkit,srvkit,common,plugin}";
|
|
14
12
|
|
|
@@ -246,11 +246,12 @@ export class ApplicationBuildRunner {
|
|
|
246
246
|
async #writeConsoleShim() {
|
|
247
247
|
await Bun.write(
|
|
248
248
|
path.join(this.#app.dist.cwdPath, "console.js"),
|
|
249
|
-
`
|
|
250
|
-
|
|
249
|
+
`process.env.AKAN_COMMAND_TYPE = "console";
|
|
250
|
+
const { cnst, db, dict, option, server, sig, srv } = await import("./server.js");
|
|
251
|
+
const { assertAkanConsoleAllowed, startAkanConsole } = await import("./console-runtime.js");
|
|
251
252
|
|
|
252
253
|
const run = async () => {
|
|
253
|
-
assertAkanConsoleAllowed(
|
|
254
|
+
assertAkanConsoleAllowed();
|
|
254
255
|
await server.start({ listen: false, web: false });
|
|
255
256
|
try {
|
|
256
257
|
await startAkanConsole(server, { globals: { cnst, db, dict, option, sig, srv } });
|
|
@@ -31,11 +31,11 @@ describe("resolveSsrPageEntries", () => {
|
|
|
31
31
|
await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
|
|
32
32
|
await write(
|
|
33
33
|
rootLayoutPath,
|
|
34
|
-
'export const theme = "dark";\nexport const fonts = [{ name: "pretendard" }];\nexport const
|
|
34
|
+
'export const theme = "dark";\nexport const fonts = [{ name: "pretendard" }];\nexport const head = null;\n',
|
|
35
35
|
);
|
|
36
36
|
await write(
|
|
37
37
|
groupedLayoutPath,
|
|
38
|
-
|
|
38
|
+
"export function generateHead() { return null; }\nexport default function Layout({ children }) { return children; }\n",
|
|
39
39
|
);
|
|
40
40
|
|
|
41
41
|
const entries = await resolveSsrPageEntries({
|
|
@@ -54,15 +54,10 @@ describe("resolveSsrPageEntries", () => {
|
|
|
54
54
|
expect(generatedSource).toContain('import * as inheritedModule from "../../../page/_layout.tsx";');
|
|
55
55
|
expect(generatedSource).toContain("const inheritedLayout = resolveRouteModule(inheritedModule as never");
|
|
56
56
|
expect(generatedSource).not.toContain("<System.Provider");
|
|
57
|
-
expect(generatedSource).toContain("export async function
|
|
58
|
-
expect(generatedSource).toContain("if (userLayout.
|
|
59
|
-
expect(generatedSource).toContain("
|
|
60
|
-
expect(generatedSource).toContain(
|
|
61
|
-
"if (inheritedLayout.generateMetadata) return inheritedLayout.generateMetadata(props);",
|
|
62
|
-
);
|
|
63
|
-
expect(generatedSource).toContain("return inheritedLayout.metadata;");
|
|
64
|
-
expect(generatedSource).not.toContain("Object.keys(userLayout.metadata)");
|
|
65
|
-
expect(generatedSource).not.toContain("Object.keys(inheritedLayout.metadata)");
|
|
57
|
+
expect(generatedSource).toContain("export async function generateHead(props: PageProps)");
|
|
58
|
+
expect(generatedSource).toContain("if (userLayout.generateHead) return userLayout.generateHead(props);");
|
|
59
|
+
expect(generatedSource).toContain("return inheritedLayout.head;");
|
|
60
|
+
expect(generatedSource).not.toContain("generateMetadata");
|
|
66
61
|
expect(generatedSource).toContain("export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;");
|
|
67
62
|
expect(generatedSource).toContain("export const Error = userLayout.Error ?? inheritedLayout.Error;");
|
|
68
63
|
expect(generatedSource).toContain("export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;");
|
|
@@ -236,13 +236,6 @@ export async function generateHead(props: PageProps) {
|
|
|
236
236
|
return inheritedLayout.head;
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
-
export async function generateMetadata(props: PageProps) {
|
|
240
|
-
if (userLayout.generateMetadata) return userLayout.generateMetadata(props);
|
|
241
|
-
if (userLayout.metadata !== undefined) return userLayout.metadata;
|
|
242
|
-
if (inheritedLayout.generateMetadata) return inheritedLayout.generateMetadata(props);
|
|
243
|
-
return inheritedLayout.metadata;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
239
|
export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
|
|
247
240
|
export const Error = userLayout.Error ?? inheritedLayout.Error;
|
|
248
241
|
export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
|
|
@@ -258,7 +251,6 @@ ${layoutBinding} return (
|
|
|
258
251
|
theme={userLayout.theme ?? inheritedLayout.theme}
|
|
259
252
|
fonts={loadFonts(userFonts)}
|
|
260
253
|
className={defaultFontClassName}
|
|
261
|
-
gaTrackingId={userLayout.gaTrackingId ?? inheritedLayout.gaTrackingId}
|
|
262
254
|
layoutStyle={userLayout.layoutStyle ?? inheritedLayout.layoutStyle}
|
|
263
255
|
reconnect={userLayout.reconnect ?? inheritedLayout.reconnect ?? false}
|
|
264
256
|
wsConnect={userLayout.wsConnect ?? inheritedLayout.wsConnect ?? true}
|
|
@@ -280,13 +272,6 @@ export async function generateHead(props: PageProps) {
|
|
|
280
272
|
return inheritedLayout.head;
|
|
281
273
|
}
|
|
282
274
|
|
|
283
|
-
export async function generateMetadata(props: PageProps) {
|
|
284
|
-
if (userLayout.generateMetadata) return userLayout.generateMetadata(props);
|
|
285
|
-
if (userLayout.metadata !== undefined) return userLayout.metadata;
|
|
286
|
-
if (inheritedLayout.generateMetadata) return inheritedLayout.generateMetadata(props);
|
|
287
|
-
return inheritedLayout.metadata;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
275
|
export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
|
|
291
276
|
export const Error = userLayout.Error ?? inheritedLayout.Error;
|
|
292
277
|
export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
|
package/biome.base.json
CHANGED
|
@@ -154,7 +154,12 @@
|
|
|
154
154
|
]
|
|
155
155
|
},
|
|
156
156
|
{
|
|
157
|
-
"includes": [
|
|
157
|
+
"includes": [
|
|
158
|
+
"{apps,libs}/**/page/**/*.ts",
|
|
159
|
+
"{apps,libs}/**/page/**/*.tsx",
|
|
160
|
+
"{apps,libs}/**/*.Unit.tsx",
|
|
161
|
+
"{apps,libs}/**/*.View.tsx"
|
|
162
|
+
],
|
|
158
163
|
"plugins": ["./node_modules/@akanjs/devkit/lint/non-scalar-props-restricted.grit"]
|
|
159
164
|
},
|
|
160
165
|
{
|
package/capacitorApp.test.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
sanitizeIosNativeRunEnv,
|
|
25
25
|
selectLocalDevHost,
|
|
26
26
|
sortIosRunTargets,
|
|
27
|
+
toIosInfoPlistUsageDescriptions,
|
|
27
28
|
writeRootCapacitorConfig,
|
|
28
29
|
} from "./capacitorApp";
|
|
29
30
|
|
|
@@ -370,3 +371,28 @@ describe("raiseGradleMinSdkVersion", () => {
|
|
|
370
371
|
expect(raiseGradleMinSdkVersion("minSdkVersion = 28", 26)).toBeNull();
|
|
371
372
|
});
|
|
372
373
|
});
|
|
374
|
+
|
|
375
|
+
describe("toIosInfoPlistUsageDescriptions", () => {
|
|
376
|
+
test("writes Apple's real Info.plist keys, not NS + the description name", () => {
|
|
377
|
+
expect(
|
|
378
|
+
toIosInfoPlistUsageDescriptions({
|
|
379
|
+
cameraUsageDescription: "camera",
|
|
380
|
+
photoAddUsageDescription: "add",
|
|
381
|
+
photoUsageDescription: "read",
|
|
382
|
+
locationAlwaysUsageDescription: "always",
|
|
383
|
+
}),
|
|
384
|
+
).toEqual({
|
|
385
|
+
NSCameraUsageDescription: "camera",
|
|
386
|
+
NSPhotoLibraryAddUsageDescription: "add",
|
|
387
|
+
NSPhotoLibraryUsageDescription: "read",
|
|
388
|
+
NSLocationAlwaysAndWhenInUseUsageDescription: "always",
|
|
389
|
+
NSLocationAlwaysUsageDescription: "always",
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("keeps the NS prefix convention for a key it does not know", () => {
|
|
394
|
+
expect(toIosInfoPlistUsageDescriptions({ bluetoothAlwaysUsageDescription: "ble" })).toEqual({
|
|
395
|
+
NSBluetoothAlwaysUsageDescription: "ble",
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
});
|
package/capacitorApp.ts
CHANGED
|
@@ -489,7 +489,7 @@ export function classifyIosRunFailure(log: string): IosRunFailureClassification
|
|
|
489
489
|
kind: "unknown",
|
|
490
490
|
title: "iOS native run failed.",
|
|
491
491
|
detail:
|
|
492
|
-
"Review the xcodebuild/devicectl output above. You can retry with --
|
|
492
|
+
"Review the xcodebuild/devicectl output above. You can retry with --no-allow-provisioning-updates to use the conservative path.",
|
|
493
493
|
};
|
|
494
494
|
}
|
|
495
495
|
|
|
@@ -597,6 +597,29 @@ export function raiseGradleMinSdkVersion(content: string, floor: number = ANDROI
|
|
|
597
597
|
return content.replace(/(minSdkVersion\s*=\s*)\d+/, `$1${floor}`);
|
|
598
598
|
}
|
|
599
599
|
|
|
600
|
+
//* Apple's Info.plist keys do not follow the description names (photo → NSPhotoLibrary…), so each one is spelled out.
|
|
601
|
+
const iosUsageDescriptionKeys = {
|
|
602
|
+
cameraUsageDescription: ["NSCameraUsageDescription"],
|
|
603
|
+
photoAddUsageDescription: ["NSPhotoLibraryAddUsageDescription"],
|
|
604
|
+
photoUsageDescription: ["NSPhotoLibraryUsageDescription"],
|
|
605
|
+
contactsUsageDescription: ["NSContactsUsageDescription"],
|
|
606
|
+
//? iOS 11+ reads the AlwaysAndWhenInUse key; the Always key only serves iOS 10 and below.
|
|
607
|
+
locationAlwaysUsageDescription: ["NSLocationAlwaysAndWhenInUseUsageDescription", "NSLocationAlwaysUsageDescription"],
|
|
608
|
+
locationWhenInUseUsageDescription: ["NSLocationWhenInUseUsageDescription"],
|
|
609
|
+
microphoneUsageDescription: ["NSMicrophoneUsageDescription"],
|
|
610
|
+
speechRecognitionUsageDescription: ["NSSpeechRecognitionUsageDescription"],
|
|
611
|
+
} as const;
|
|
612
|
+
|
|
613
|
+
export const toIosInfoPlistUsageDescriptions = (descriptions: { [key: string]: string }) =>
|
|
614
|
+
Object.fromEntries(
|
|
615
|
+
Object.entries(descriptions).flatMap(([key, value]) => {
|
|
616
|
+
const plistKeys: readonly string[] = Object.hasOwn(iosUsageDescriptionKeys, key)
|
|
617
|
+
? iosUsageDescriptionKeys[key as keyof typeof iosUsageDescriptionKeys]
|
|
618
|
+
: [`NS${capitalize(key)}`];
|
|
619
|
+
return plistKeys.map((plistKey) => [plistKey, value]);
|
|
620
|
+
}),
|
|
621
|
+
);
|
|
622
|
+
|
|
600
623
|
const mergeAllowNavigation = (configured: unknown, localIp: string | undefined) => {
|
|
601
624
|
const values = Array.isArray(configured)
|
|
602
625
|
? configured.filter((value): value is string => typeof value === "string")
|
|
@@ -1469,9 +1492,7 @@ export class CapacitorApp {
|
|
|
1469
1492
|
if (next !== content) await editor.setContent(next).save();
|
|
1470
1493
|
}
|
|
1471
1494
|
async #setPermissionInIos(permissions: { [key: string]: string }) {
|
|
1472
|
-
const updateNs =
|
|
1473
|
-
Object.entries(permissions).map(([key, value]) => [`NS${capitalize(key)}`, value]),
|
|
1474
|
-
);
|
|
1495
|
+
const updateNs = toIosInfoPlistUsageDescriptions(permissions);
|
|
1475
1496
|
await Promise.all([
|
|
1476
1497
|
this.project.ios.updateInfoPlist(this.iosTargetName, "Debug", updateNs),
|
|
1477
1498
|
this.project.ios.updateInfoPlist(this.iosTargetName, "Release", updateNs),
|
|
@@ -66,22 +66,29 @@ export class AkanCodeServices {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* The akan skill set, then the
|
|
69
|
+
* The akan skill set, then the person's own, then the workspace's.
|
|
70
70
|
*
|
|
71
|
-
*
|
|
71
|
+
* `~/.akan/code/skills` is read because a skill somebody wrote for themselves is theirs, not one checkout's,
|
|
72
|
+
* and having to copy it into every repo is the same friction that makes people stop writing them. It is
|
|
73
|
+
* text the model may read, never code that runs — which is why extensions stay workspace-only below.
|
|
74
|
+
*
|
|
75
|
+
* A path that does not exist is a reported load error rather than a skip, so each is checked first.
|
|
72
76
|
*/
|
|
73
77
|
static #skillPaths(profile: CodeAgentProfile, workspaceRoot: string) {
|
|
74
78
|
if (!profile.context.skills) return [];
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
(
|
|
78
|
-
|
|
79
|
+
const dirs = [
|
|
80
|
+
akanCodePaths.builtinSkillsDir(),
|
|
81
|
+
akanCodePaths.globalSkillsDir(),
|
|
82
|
+
akanCodePaths.skillsDir(workspaceRoot),
|
|
83
|
+
];
|
|
84
|
+
return dirs.filter((dir): dir is string => !!dir && existsSync(dir));
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
/**
|
|
82
|
-
* What the engine's resource loader may discover. Extensions
|
|
83
|
-
*
|
|
84
|
-
*
|
|
88
|
+
* What the engine's resource loader may discover. Extensions come from the workspace and nowhere else: a
|
|
89
|
+
* globally installed extension is code that runs in every checkout, so an agent would behave differently in
|
|
90
|
+
* two clones of the same repo for reasons nothing in the repo explains. Skills are text and are read from
|
|
91
|
+
* the home directory too — see `#skillPaths`.
|
|
85
92
|
*/
|
|
86
93
|
static resourceOptions(options: AkanCodeServicesOptions) {
|
|
87
94
|
// A path that does not exist is reported as a load error rather than skipped, and these two are optional.
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type CodeAgentImage,
|
|
16
16
|
type CodeAgentMcpStatus,
|
|
17
17
|
type CodeAgentProfile,
|
|
18
|
+
type CodeAgentProviderInfo,
|
|
18
19
|
type CodeAgentQuestion,
|
|
19
20
|
type CodeAgentSessionInfo,
|
|
20
21
|
type CodeAgentState,
|
|
@@ -121,6 +122,7 @@ export class CodeAgent {
|
|
|
121
122
|
mailbox: () => agent.#mailbox,
|
|
122
123
|
onNotice: (message) => agent.#emit({ type: "notice", level: "warning", message }),
|
|
123
124
|
onAgents: (agents) => agent.#emit({ type: "subagent", agents }),
|
|
125
|
+
onAsk: (question) => agent.ask(question),
|
|
124
126
|
});
|
|
125
127
|
|
|
126
128
|
const resourceLoader = new DefaultResourceLoader({
|
|
@@ -281,6 +283,7 @@ export class CodeAgent {
|
|
|
281
283
|
const text = CodeAgent.#textOf(message.content);
|
|
282
284
|
if (text) this.#emit({ type: "message", turnId, role: message.role, text });
|
|
283
285
|
}
|
|
286
|
+
this.#emitContextNow();
|
|
284
287
|
}
|
|
285
288
|
|
|
286
289
|
static #textOf(content: unknown) {
|
|
@@ -400,6 +403,33 @@ export class CodeAgent {
|
|
|
400
403
|
this.#emit({ type: "session", info: this.info });
|
|
401
404
|
}
|
|
402
405
|
|
|
406
|
+
/**
|
|
407
|
+
* The model catalogue, as the two questions a person actually asks of it.
|
|
408
|
+
*
|
|
409
|
+
* Built from the registry rather than `getAvailable()` so it needs no await and can answer about a provider
|
|
410
|
+
* nobody has a key for: "what else could I run" is most of why the list is opened, and a list of only what
|
|
411
|
+
* is already reachable cannot answer it.
|
|
412
|
+
*/
|
|
413
|
+
catalogue(): CodeAgentProviderInfo[] {
|
|
414
|
+
const runtime = this.#session?.modelRuntime;
|
|
415
|
+
if (!runtime) return [];
|
|
416
|
+
const current = this.#session?.model;
|
|
417
|
+
return runtime
|
|
418
|
+
.getProviders()
|
|
419
|
+
.map((provider) => ({
|
|
420
|
+
id: provider.id,
|
|
421
|
+
name: provider.name ?? provider.id,
|
|
422
|
+
authorized: runtime.hasConfiguredAuth(provider.id),
|
|
423
|
+
models: (runtime.getModels(provider.id) ?? []).map((model) => ({
|
|
424
|
+
id: model.id,
|
|
425
|
+
name: model.name,
|
|
426
|
+
...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
|
|
427
|
+
current: model.provider === current?.provider && model.id === current?.id,
|
|
428
|
+
})),
|
|
429
|
+
}))
|
|
430
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
431
|
+
}
|
|
432
|
+
|
|
403
433
|
async setModel(ref: CodeAgentModelRef) {
|
|
404
434
|
const session = this.#require();
|
|
405
435
|
const model = await akanCodeModel(session.modelRuntime, ref);
|
|
@@ -461,6 +491,17 @@ export class CodeAgent {
|
|
|
461
491
|
*/
|
|
462
492
|
#emitContextUsage(eventType: string) {
|
|
463
493
|
if (eventType !== "agent_end" && eventType !== "compaction_end") return;
|
|
494
|
+
this.#emitContextNow();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The same frame, on demand, for the one moment the window is already full and no turn has run.
|
|
499
|
+
*
|
|
500
|
+
* A resumed session restores a conversation the model is carrying and the host has never seen, so without
|
|
501
|
+
* this the status line offers the declared window and no share of it until the next turn ends — a session
|
|
502
|
+
* reopened at 80% looks identical to one reopened empty.
|
|
503
|
+
*/
|
|
504
|
+
#emitContextNow() {
|
|
464
505
|
const usage = this.#session?.getContextUsage();
|
|
465
506
|
// `== null`, not `=== null`: the field is typed `number | null`, and a strict null check sails straight
|
|
466
507
|
// past an `undefined` the way the mirror of this bug does — which would emit `used: undefined`.
|
|
@@ -537,25 +578,33 @@ export class CodeAgent {
|
|
|
537
578
|
return await this.#asks.openApproval(approvalId);
|
|
538
579
|
}
|
|
539
580
|
|
|
581
|
+
/**
|
|
582
|
+
* Puts one question on the wire and waits for its answer, rendered as the labels a person chose.
|
|
583
|
+
*
|
|
584
|
+
* The single way anything asks: the engine's own UI port and the `ask_user` tool both land here, so the
|
|
585
|
+
* suspend path, the profile's refusal to prompt at all, and the id the host answers against cannot disagree
|
|
586
|
+
* between them. An empty answer is a skip, which is a real answer and not a failure.
|
|
587
|
+
*/
|
|
588
|
+
async ask(spec: Omit<CodeAgentQuestion, "questionId">) {
|
|
589
|
+
if (!this.#profile.ui.canPrompt) return undefined;
|
|
590
|
+
const question: CodeAgentQuestion = { questionId: this.#asks.nextId("q"), ...spec };
|
|
591
|
+
this.#emit({ type: "question", question });
|
|
592
|
+
if (this.#profile.interaction.question === "suspend") {
|
|
593
|
+
this.#mapper.noteOutcome("awaiting");
|
|
594
|
+
return undefined;
|
|
595
|
+
}
|
|
596
|
+
return (await this.#asks.openQuestion(question)) || undefined;
|
|
597
|
+
}
|
|
598
|
+
|
|
540
599
|
#ui() {
|
|
541
600
|
return new CodeAgentUi({
|
|
542
|
-
ask: async (prompt, kind, choices) =>
|
|
543
|
-
|
|
544
|
-
const questionId = this.#asks.nextId("q");
|
|
545
|
-
const question: CodeAgentQuestion = {
|
|
546
|
-
questionId,
|
|
601
|
+
ask: async (prompt, kind, choices) =>
|
|
602
|
+
await this.ask({
|
|
547
603
|
prompt,
|
|
548
604
|
kind,
|
|
549
605
|
...(choices ? { options: choices.map((label) => ({ key: label, label })) } : {}),
|
|
550
606
|
freeText: kind === "text",
|
|
551
|
-
}
|
|
552
|
-
this.#emit({ type: "question", question });
|
|
553
|
-
if (this.#profile.interaction.question === "suspend") {
|
|
554
|
-
this.#mapper.noteOutcome("awaiting");
|
|
555
|
-
return undefined;
|
|
556
|
-
}
|
|
557
|
-
return (await this.#asks.openQuestion(question)) || undefined;
|
|
558
|
-
},
|
|
607
|
+
}),
|
|
559
608
|
notify: (level, message) => this.#emit({ type: "notice", level, message }),
|
|
560
609
|
});
|
|
561
610
|
}
|
|
@@ -12,6 +12,15 @@ const builtinSkillsDir = () => {
|
|
|
12
12
|
return candidates.find((candidate) => existsSync(candidate));
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Where `akan code` keeps what belongs to the person rather than to the repo.
|
|
17
|
+
*
|
|
18
|
+
* `AKAN_CODE_HOME` moves the whole set. A container that mounts no home directory, a CI job that must not
|
|
19
|
+
* write to one, and a second checkout that wants its own credentials all need the same thing, and moving the
|
|
20
|
+
* files apart would let a `models.json` and the `auth.json` it names drift into different directories.
|
|
21
|
+
*/
|
|
22
|
+
const globalDir = () => process.env.AKAN_CODE_HOME ?? path.join(homedir(), ".akan", "code");
|
|
23
|
+
|
|
15
24
|
/**
|
|
16
25
|
* Sessions live in the workspace — they are project history, and a second checkout of the same repo is a
|
|
17
26
|
* different project. Credentials live in the home directory instead: a repo directory is the one place a key
|
|
@@ -25,7 +34,18 @@ export const akanCodePaths = {
|
|
|
25
34
|
mailDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "mail"),
|
|
26
35
|
builtinSkillsDir,
|
|
27
36
|
mcpFile: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "mcp.json"),
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
/**
|
|
38
|
+
* The MCP servers that belong to the person rather than to one checkout.
|
|
39
|
+
*
|
|
40
|
+
* A server is an integration with an account — the credential for it is already global, in `mcpAuth.json` —
|
|
41
|
+
* so declaring it per repo means declaring it again in every repo and signing in again in each. The
|
|
42
|
+
* workspace file stays, for a server that is genuinely this repo's, and wins on a name they share.
|
|
43
|
+
*/
|
|
44
|
+
globalMcpFile: () => path.join(globalDir(), "mcp.json"),
|
|
45
|
+
globalSkillsDir: () => path.join(globalDir(), "skills"),
|
|
46
|
+
globalDir,
|
|
47
|
+
authFile: () => path.join(globalDir(), "auth.json"),
|
|
48
|
+
/** MCP bearer tokens, beside the provider keys and for the same reason: never in a directory that is shared. */
|
|
49
|
+
mcpAuthFile: () => path.join(globalDir(), "mcpAuth.json"),
|
|
50
|
+
modelsFile: () => path.join(globalDir(), "models.json"),
|
|
31
51
|
} as const;
|
|
@@ -14,6 +14,7 @@ export const akanSystemPrompt = (profile: CodeAgentProfile) => {
|
|
|
14
14
|
"- Prefer an akan workflow to a direct source edit. `list_workflows` / `explain_workflow` / `plan_workflow`, then `apply_workflow({ planPath })`. Editing source by hand is the fallback, not the default.",
|
|
15
15
|
"- Read context with `inspect_akan_context` before reading source bodies; it answers most structural questions without spending the file.",
|
|
16
16
|
"- Fetch the guideline for an area before a deep pass on it: `get_guideline`.",
|
|
17
|
+
"- A question about `akan code` itself — its slash commands, MCP servers, sessions, models, keys, or where its files live — is answered by the `akan-code` skill. Read it rather than describing the tool from memory.",
|
|
17
18
|
"- Never hand-edit a generated file (`lib/cnst.ts`, `db.ts`, `st.ts`, any `*/index.ts` barrel). Fix the owning source and run `repair_generated` or `akan sync`.",
|
|
18
19
|
];
|
|
19
20
|
if (profile.tools.builtin.includes("write"))
|
|
@@ -28,6 +29,10 @@ export const akanSystemPrompt = (profile: CodeAgentProfile) => {
|
|
|
28
29
|
lines.push(
|
|
29
30
|
'- Hand a self-contained search or read to `task` — `type: "explore"` reads and reports, `type: "code"` makes the change. Only its answer comes back, so give it everything it needs and ask for exactly what you want returned. Do it yourself when the reading is short or when you need the files in front of you.',
|
|
30
31
|
);
|
|
32
|
+
if (profile.ui.canPrompt)
|
|
33
|
+
lines.push(
|
|
34
|
+
"- Ask with `ask_user` when two readings of the request would lead to materially different work, and offer the options you can name. Do not ask for a choice that has an obvious default, and do not ask permission for work you were already asked to do — make the call, say the assumption, and carry on.",
|
|
35
|
+
);
|
|
31
36
|
if (profile.approval !== "never")
|
|
32
37
|
lines.push(
|
|
33
38
|
`- Some tool calls need the user's approval (policy: ${profile.approval}). A refusal is an answer; do not retry it a different way.`,
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: akan-code
|
|
3
|
+
description: "Answer a question about `akan code` itself — how to add or remove an MCP server, sign one in, switch model, fork or resume a session, what the slash commands and keys are, and where its files live. Read this before telling anyone how to use this tool."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# akan code — the manual
|
|
7
|
+
|
|
8
|
+
This is the tool the user is talking to right now. When they ask how to do something *with it* — rather than
|
|
9
|
+
with their code — answer from here, and quote the exact gesture: the slash command to type, or the file to
|
|
10
|
+
edit. Do not guess a command; everything this tool offers is listed below.
|
|
11
|
+
|
|
12
|
+
You cannot type a slash command yourself. They are the host's, not yours — say which one to type. You *can*
|
|
13
|
+
edit the files named below, which is the useful half of most of these answers: write the change, then say
|
|
14
|
+
which command makes it take effect.
|
|
15
|
+
|
|
16
|
+
## MCP servers
|
|
17
|
+
|
|
18
|
+
Declared in **`.akan/code/mcp.json`**, in the same `mcpServers` shape Claude, Cursor and VS Code already
|
|
19
|
+
write, so a block can be pasted across rather than translated. VS Code's `servers` spelling is read too, and
|
|
20
|
+
whichever key the file already uses is the one a write goes back into.
|
|
21
|
+
|
|
22
|
+
```jsonc
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"] },
|
|
26
|
+
"linear": { "url": "https://mcp.linear.app/sse" },
|
|
27
|
+
"internal": { "url": "https://…/mcp", "headers": { "x-api-key": "…" } },
|
|
28
|
+
"old": { "command": "x", "disabled": true }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A `url` is an http server; anything else is a command with its argv. `env` passes variables to a stdio
|
|
34
|
+
server, `headers` goes on every http request (a server behind a static token needs nothing else), and
|
|
35
|
+
`disabled: true` keeps an entry without connecting it.
|
|
36
|
+
|
|
37
|
+
From the prompt:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
/mcp what is declared, what connected, and how many tools each gave
|
|
41
|
+
/mcp <name> the tools that one server published
|
|
42
|
+
/mcp add <name> <command> [args…]
|
|
43
|
+
/mcp add <name> <https://…>
|
|
44
|
+
/mcp remove <name>
|
|
45
|
+
/mcp login <name> OAuth, in the browser
|
|
46
|
+
/mcp logout <name> forget the token, leaving the server declared
|
|
47
|
+
/mcp reload reopen this session so the file takes effect
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**A server added now is not reachable until the session reloads.** The tool allowlist is fixed when the
|
|
51
|
+
session is created, so `add`, `remove`, `login` and `logout` all reload by themselves — a session that has
|
|
52
|
+
said nothing yet has nothing on disk to reopen, and says so. If you edit `mcp.json` yourself, tell the user to
|
|
53
|
+
run `/mcp reload`.
|
|
54
|
+
|
|
55
|
+
Sign-in is discovered, not configured: the server's `401` names its resource metadata, that names the
|
|
56
|
+
authorization server, and most hosted servers register a client on demand — so a url is usually the whole
|
|
57
|
+
entry. For a provider that issues client ids by hand, add `"oauth": { "clientId": "…", "scope": "…" }`.
|
|
58
|
+
Tokens are stored per server in `~/.akan/code/mcpAuth.json` at mode 0600, never in the repo, and refreshed
|
|
59
|
+
silently; `/mcp` shows `sign-in needed` when a server wants one.
|
|
60
|
+
|
|
61
|
+
## Sessions
|
|
62
|
+
|
|
63
|
+
Stored under `.akan/code/sessions` — project history, so a second checkout of the same repo has its own.
|
|
64
|
+
|
|
65
|
+
- `←` at the start of the prompt lists past sessions; enter reopens the chosen one.
|
|
66
|
+
- `akan code --resume <id>` (`-R`) reopens one from the shell.
|
|
67
|
+
- `/fork [name]` continues the conversation twice: the copy takes over here, and the notice names the command
|
|
68
|
+
that reopens the original.
|
|
69
|
+
- `/name <text>` names this one, which is what the session list is read by.
|
|
70
|
+
- `/peers` lists other `akan code` sessions running in this workspace; `/msg <peer> <text>` hands one a
|
|
71
|
+
message.
|
|
72
|
+
- `/clear` wipes the screen and keeps the conversation; `/compact [notes]` shortens what the model carries.
|
|
73
|
+
|
|
74
|
+
## Model, effort, keys
|
|
75
|
+
|
|
76
|
+
- `/model <provider>/<id>`, e.g. `/model deepseek/deepseek-v4-pro`. `/effort <level>` for how hard it thinks.
|
|
77
|
+
- A provider is authorized by **`<PROVIDER>_API_KEY`** — `DEEPSEEK_API_KEY`, `ANTHROPIC_API_KEY`. The
|
|
78
|
+
workspace's own `.env` is read as well as the environment, and a key found there is used for the run and
|
|
79
|
+
never copied anywhere.
|
|
80
|
+
- Credentials and the model catalogue live in `~/.akan/code/` (`auth.json`, `models.json`). **`AKAN_CODE_HOME`
|
|
81
|
+
moves the whole directory** — for a container with no home, or a checkout that wants its own.
|
|
82
|
+
|
|
83
|
+
## What the agent reads, and how to change it
|
|
84
|
+
|
|
85
|
+
- Every `AGENTS.md` / `CLAUDE.md` from the workspace root down to the working directory, `.cursorrules`, and
|
|
86
|
+
any `.cursor/rules/*.mdc` that applies unconditionally (`alwaysApply: true`, or no frontmatter at all).
|
|
87
|
+
Edit those to change how this agent behaves in this repo — they are the files the editor beside it reads.
|
|
88
|
+
- Skills: drop a `<name>/SKILL.md` into `.akan/code/skills`. Extensions: `.akan/code/extensions`. Neither is
|
|
89
|
+
read from a home directory, so a session behaves the same in two checkouts.
|
|
90
|
+
|
|
91
|
+
## Keys
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
enter send · shift+enter newline · ↑↓ move (history at the edges) · ←→ move
|
|
95
|
+
esc interrupt · pgup/pgdn scroll · ^c quit · tab completes a slash command
|
|
96
|
+
@ completes a file in this repo · wheel scrolls the transcript (hold shift to select text instead)
|
|
97
|
+
cmd+v attaches an image from the clipboard · ^v does too, without the terminal in the way
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Most terminals send the same byte for enter and shift+enter, so the key only arrives where the terminal can
|
|
101
|
+
say which was pressed. In VS Code, bind it with `workbench.action.terminal.sendSequence` and a `text` of
|
|
102
|
+
`"\\\u001b\r"`; iTerm2 calls the same thing "Send Text". Either spelling of that sequence works here, and
|
|
103
|
+
the backslash a shell would read as a line continuation never reaches the prompt.
|
|
104
|
+
|
|
105
|
+
## Starting it
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
akan code open the session
|
|
109
|
+
akan code "<prompt>" run one turn and print the answer
|
|
110
|
+
akan code -i "<prompt>" open the session with the prompt already sent
|
|
111
|
+
--app <name> narrow the agent to one app
|
|
112
|
+
--profile local|pod|review|web what it may do (default local)
|
|
113
|
+
--model <provider>/<id> --thinking, --json, --rpc, --resume <id>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`review` reads and never writes, loads no project context files, and keeps no session on disk. `pod` has
|
|
117
|
+
nobody in front of it: it cannot ask a question. MCP is off in both.
|
|
118
|
+
|
|
119
|
+
## The rest of the commands
|
|
120
|
+
|
|
121
|
+
`/help` prints all of this list · `/tools` what this session can call · `/agents` what a sub-agent may be and
|
|
122
|
+
what one may spend · `/thinking` shows or folds the reasoning · `/abort` stops the running turn · `/quit`.
|
|
@@ -41,5 +41,5 @@ module. Collection-level helpers go `static` on the full model.
|
|
|
41
41
|
- Read the module's `<module>.abstract.md` before changing it, and update it when an invariant or workflow
|
|
42
42
|
changes — not for formatting.
|
|
43
43
|
|
|
44
|
-
Deeper detail per layer is a `get_guideline` call away: `moduleOverview`, `
|
|
45
|
-
`
|
|
44
|
+
Deeper detail per layer is a `get_guideline` call away: `moduleOverview`, `modelConstant`, `modelDocument`,
|
|
45
|
+
`modelService`, `modelSignal`, `modelStore`, `modelDictionary`.
|
|
@@ -37,14 +37,18 @@ Only what the generated set does not cover: extra client state, a multi-step or
|
|
|
37
37
|
calls to the non-CRUD endpoints you added in the signal. Each is an `async` method using `this.set(...)` /
|
|
38
38
|
`this.get()` / `this.pick(...)`.
|
|
39
39
|
|
|
40
|
+
To reach another store, cast `this`: `import type { RootStore } from "../st"`, then
|
|
41
|
+
`(this as unknown as RootStore).logout()` to call its action or `(this as unknown as RootStore).set({ … })` /
|
|
42
|
+
`.get()` to write or read its state. Every store is mixed into one root at runtime, so the cast only tells the
|
|
43
|
+
type what `this` already is. Keep it `import type` — `st.ts` imports every store, so a value import is a cycle.
|
|
44
|
+
|
|
40
45
|
A store that needs none of this stays empty. `store(sig.comment, () => ({}))` with an empty body is the
|
|
41
46
|
correct, complete result — an empty store is not an unfinished one.
|
|
42
47
|
|
|
43
|
-
##
|
|
48
|
+
## The rule the linter enforces
|
|
44
49
|
|
|
45
50
|
- **An action returns nothing.** Every method dispatches through `st.do.<action>()` and is typed `void`, so a
|
|
46
51
|
returned value is unreachable. Write it into state with `this.set({ … })`.
|
|
47
|
-
- **Never `import type { RootStore } from "../st"`** — it crashes `akan build` with a Bun SSR segfault.
|
|
48
52
|
|
|
49
53
|
## From UI
|
|
50
54
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { CodeAgentMcpStatus, CodeAgentProfile, CodeAgentSubagent } from "akanjs/common";
|
|
2
|
+
import type { CodeAgentMcpStatus, CodeAgentProfile, CodeAgentQuestion, CodeAgentSubagent } from "akanjs/common";
|
|
3
3
|
import type { Workspace } from "../../commandDecorators";
|
|
4
4
|
import type { CodeMailbox } from "../agent/CodeMailbox";
|
|
5
5
|
import { SubagentPool } from "../agent/SubagentPool";
|
|
@@ -9,6 +9,7 @@ import { PreviewView } from "../feedback/PreviewView";
|
|
|
9
9
|
import { TurnFeedback, type TurnFeedbackSource } from "../feedback/TurnFeedback";
|
|
10
10
|
import { VerifyFeedback } from "../feedback/VerifyFeedback";
|
|
11
11
|
import { AkanToolPack } from "./AkanToolPack";
|
|
12
|
+
import { AskToolPack } from "./AskToolPack";
|
|
12
13
|
import { MailToolPack } from "./MailToolPack";
|
|
13
14
|
import { McpToolPack } from "./McpToolPack";
|
|
14
15
|
import { SessionToolPack } from "./SessionToolPack";
|
|
@@ -29,6 +30,8 @@ export interface AkanCodePluginsOptions {
|
|
|
29
30
|
mailbox: () => CodeMailbox | undefined;
|
|
30
31
|
onNotice?: (message: string) => void;
|
|
31
32
|
onAgents?: (agents: CodeAgentSubagent[]) => void;
|
|
33
|
+
/** How `ask_user` reaches the person. Absent on a host that has nobody to reach. */
|
|
34
|
+
onAsk?: (question: Omit<CodeAgentQuestion, "questionId">) => Promise<string | undefined>;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/**
|
|
@@ -67,10 +70,21 @@ export class AkanCodePlugins {
|
|
|
67
70
|
const sessionExtension = sessions.extension();
|
|
68
71
|
const mail = new MailToolPack({ profile: options.profile, mailbox: options.mailbox });
|
|
69
72
|
const mailExtension = mail.extension();
|
|
73
|
+
const onAsk = options.onAsk;
|
|
74
|
+
const ask = onAsk ? new AskToolPack({ profile: options.profile, ask: onAsk }) : undefined;
|
|
75
|
+
const askExtension = ask?.extension();
|
|
70
76
|
if (!options.profile.tools.akan)
|
|
71
77
|
return {
|
|
72
|
-
extensions: [mcp?.extension(), webExtension, sessionExtension, mailExtension].filter(
|
|
73
|
-
|
|
78
|
+
extensions: [mcp?.extension(), webExtension, sessionExtension, mailExtension, askExtension].filter(
|
|
79
|
+
(entry) => !!entry,
|
|
80
|
+
),
|
|
81
|
+
toolNames: [
|
|
82
|
+
...(mcp?.toolNames ?? []),
|
|
83
|
+
...web.names(),
|
|
84
|
+
...sessions.names(),
|
|
85
|
+
...mail.names(),
|
|
86
|
+
...(ask?.names() ?? []),
|
|
87
|
+
],
|
|
74
88
|
mcp: mcpStatus,
|
|
75
89
|
dispose,
|
|
76
90
|
};
|
|
@@ -102,6 +116,7 @@ export class AkanCodePlugins {
|
|
|
102
116
|
if (webExtension) extensions.push(webExtension);
|
|
103
117
|
if (sessionExtension) extensions.push(sessionExtension);
|
|
104
118
|
if (mailExtension) extensions.push(mailExtension);
|
|
119
|
+
if (askExtension) extensions.push(askExtension);
|
|
105
120
|
return {
|
|
106
121
|
extensions,
|
|
107
122
|
toolNames: [
|
|
@@ -111,6 +126,7 @@ export class AkanCodePlugins {
|
|
|
111
126
|
...web.names(),
|
|
112
127
|
...sessions.names(),
|
|
113
128
|
...mail.names(),
|
|
129
|
+
...(ask?.names() ?? []),
|
|
114
130
|
],
|
|
115
131
|
mcp: mcpStatus,
|
|
116
132
|
dispose,
|