@kontextmind/kxm 0.7.97 → 0.7.99
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/.claude-plugin/marketplace.json +1 -1
- package/.kxm/workflows/default.yaml +2 -0
- package/CHANGELOG.md +46 -2
- package/docs/concepts/architecture.md +1 -1
- package/docs/concepts/data-and-storage.md +1 -1
- package/docs/contracts/routing.md +1 -1
- package/docs/contributing/test-matrix.md +6 -5
- package/docs/guides/peer-messaging.md +2 -2
- package/docs/operations/backup-and-restore.md +43 -24
- package/docs/operations/deploy.md +1 -1
- package/docs/reference/cli-reference.md +65 -28
- package/docs/reference/config-reference.md +24 -13
- package/docs/reference/harness-routing.md +3 -3
- package/docs/reference/http-api.md +2 -1
- package/docs/reference/tools.md +3 -3
- package/docs/start/first-workflow.md +4 -4
- package/docs/start/quickstart-claude-code.md +2 -2
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/claude-hook.js +4 -1
- package/plugins/kxm/dist/cli.js +407 -156
- package/plugins/kxm/dist/client.js +9 -0
- package/plugins/kxm/dist/core.js +9 -3
- package/plugins/kxm/dist/extension.js +13 -1
- package/plugins/kxm/dist/mcp-server.js +14 -2
- package/plugins/kxm/dist/runtime-supervisor.js +231 -48
- package/plugins/kxm/dist/runtime.js +415 -92
- package/plugins/kxm/dist/server.js +19 -1
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +4 -3
- package/plugins/kxm/skills/kxm-peer/SKILL.md +5 -3
- package/plugins/kxm/skills/kxm-project-setup/SKILL.md +3 -2
- package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +3 -0
- package/plugins/kxm/skills/kxm-runs/SKILL.md +8 -7
- package/plugins/kxm/src/cli/project.ts +22 -13
- package/plugins/kxm/src/cli/system.ts +22 -1
- package/plugins/kxm/src/cli.ts +14 -4
- package/plugins/kxm/src/client.ts +10 -0
- package/plugins/kxm/src/commands.ts +10 -1
- package/plugins/kxm/src/database.ts +210 -36
- package/plugins/kxm/src/engine.ts +117 -2
- package/plugins/kxm/src/extension.ts +1 -0
- package/plugins/kxm/src/harness.ts +29 -0
- package/plugins/kxm/src/hub.ts +12 -0
- package/plugins/kxm/src/init-guide-setup.ts +43 -28
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/oneshot-producer.ts +16 -8
- package/plugins/kxm/src/prices.ts +33 -2
- package/plugins/kxm/src/routing.ts +13 -7
- package/plugins/kxm/src/studio-layout.ts +5 -4
- package/plugins/kxm/src/template.ts +31 -0
- package/plugins/kxm/src/worktree-witness.ts +71 -0
- package/schemas/backup-manifest.schema.json +33 -0
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "node:fs";
|
|
13
13
|
import { basename, dirname, join, resolve } from "node:path";
|
|
14
14
|
import { DatabaseSync } from "./sqlite.ts";
|
|
15
|
+
import { kxmUserStateRoot } from "./bindings.ts";
|
|
15
16
|
import { KxmConfigError, type KxmConfigIssue } from "./project-config.ts";
|
|
16
17
|
|
|
17
18
|
export interface DatabaseSchemaSpec {
|
|
@@ -31,12 +32,24 @@ export interface BackupStoreRecord {
|
|
|
31
32
|
integrity: "ok";
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
export interface BackupFileRecord {
|
|
36
|
+
id: string;
|
|
37
|
+
sourcePath: string;
|
|
38
|
+
backupFile: string;
|
|
39
|
+
sha256: string;
|
|
40
|
+
bytes: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
export interface BackupManifest {
|
|
35
44
|
schema: "kxm.backup-manifest.v1";
|
|
36
45
|
backupId: string;
|
|
37
46
|
createdAt: string;
|
|
38
47
|
projectRoot?: string;
|
|
39
48
|
stores: BackupStoreRecord[];
|
|
49
|
+
files?: BackupFileRecord[];
|
|
50
|
+
/** False when a discovered store or prompt sidecar was left out. Absent on legacy manifests. */
|
|
51
|
+
complete?: boolean;
|
|
52
|
+
omitted?: string[];
|
|
40
53
|
manifestSha256?: string;
|
|
41
54
|
}
|
|
42
55
|
|
|
@@ -607,46 +620,136 @@ export const KXM_BACKUP_CEILINGS = {
|
|
|
607
620
|
} as const;
|
|
608
621
|
|
|
609
622
|
export function kxmBackupCeiling(storeId: string): number {
|
|
623
|
+
if (storeId === "runtime-registry") return KXM_BACKUP_CEILINGS.registry;
|
|
610
624
|
if (storeId.startsWith("events:")) return KXM_BACKUP_CEILINGS.events;
|
|
611
625
|
// An id this build does not know keeps the ceiling restore has always defaulted to.
|
|
612
626
|
return KXM_BACKUP_CEILINGS[storeId as keyof typeof KXM_BACKUP_CEILINGS] ?? KXM_BACKUP_CEILINGS["hub-store"];
|
|
613
627
|
}
|
|
614
628
|
|
|
615
|
-
|
|
629
|
+
interface DiscoveredStore {
|
|
630
|
+
storeId: string;
|
|
631
|
+
sourcePath: string;
|
|
632
|
+
maxSupportedVersion: number;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
interface DiscoveredFile {
|
|
636
|
+
id: string;
|
|
637
|
+
sourcePath: string;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function pushStore(stores: DiscoveredStore[], storeId: string, sourcePath: string): void {
|
|
641
|
+
if (stores.some((store) => store.storeId === storeId || store.sourcePath === sourcePath)) return;
|
|
642
|
+
stores.push({ storeId, sourcePath, maxSupportedVersion: kxmBackupCeiling(storeId) });
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function discoverUserRuntime(env: NodeJS.ProcessEnv | undefined, stores: DiscoveredStore[], files: DiscoveredFile[]): void {
|
|
646
|
+
let stateRoot: string;
|
|
647
|
+
try {
|
|
648
|
+
stateRoot = kxmUserStateRoot(env ? { env } : {});
|
|
649
|
+
} catch {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const runtimeDir = join(stateRoot, "runtime");
|
|
653
|
+
const registryPath = join(runtimeDir, "registry.db");
|
|
654
|
+
if (existsSync(registryPath)) {
|
|
655
|
+
const storeId = stores.some((store) => store.storeId === "registry") ? "runtime-registry" : "registry";
|
|
656
|
+
pushStore(stores, storeId, registryPath);
|
|
657
|
+
}
|
|
658
|
+
const projectsDir = join(runtimeDir, "projects");
|
|
659
|
+
if (!existsSync(projectsDir)) return;
|
|
660
|
+
let entries;
|
|
661
|
+
try {
|
|
662
|
+
entries = readdirSync(projectsDir, { withFileTypes: true });
|
|
663
|
+
} catch {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
for (const entry of entries) {
|
|
667
|
+
if (!entry.isDirectory()) continue;
|
|
668
|
+
const dbPath = join(projectsDir, entry.name, "run-events.db");
|
|
669
|
+
if (!existsSync(dbPath)) continue;
|
|
670
|
+
const safe = entry.name.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
671
|
+
let storeId = `events:${safe}`;
|
|
672
|
+
if (stores.some((store) => store.storeId === storeId)) storeId = `events:runtime:${safe}`;
|
|
673
|
+
pushStore(stores, storeId, dbPath);
|
|
674
|
+
const sidecar = `${dbPath}.run-prompts.json`;
|
|
675
|
+
if (existsSync(sidecar)) {
|
|
676
|
+
const id = `${storeId}:run-prompts`;
|
|
677
|
+
if (!files.some((file) => file.id === id || file.sourcePath === sidecar)) {
|
|
678
|
+
files.push({ id, sourcePath: sidecar });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function discoverBackupSources(
|
|
685
|
+
projectRoot: string,
|
|
686
|
+
options: { hubDataPath?: string; env?: NodeJS.ProcessEnv } = {},
|
|
687
|
+
): { stores: DiscoveredStore[]; files: DiscoveredFile[] } {
|
|
616
688
|
const root = resolve(projectRoot);
|
|
617
|
-
const stores:
|
|
689
|
+
const stores: DiscoveredStore[] = [];
|
|
690
|
+
const files: DiscoveredFile[] = [];
|
|
618
691
|
|
|
619
692
|
const hubPath = options.hubDataPath ? resolve(options.hubDataPath) : join(root, ".kxm", "state", "kxm.db");
|
|
620
|
-
if (existsSync(hubPath))
|
|
621
|
-
stores.push({ storeId: "hub-store", sourcePath: hubPath, maxSupportedVersion: kxmBackupCeiling("hub-store") });
|
|
622
|
-
}
|
|
693
|
+
if (existsSync(hubPath)) pushStore(stores, "hub-store", hubPath);
|
|
623
694
|
|
|
624
695
|
const registryPath = join(root, ".kxm", "runtime", "registry.db");
|
|
625
|
-
if (existsSync(registryPath))
|
|
626
|
-
stores.push({ storeId: "registry", sourcePath: registryPath, maxSupportedVersion: kxmBackupCeiling("registry") });
|
|
627
|
-
}
|
|
696
|
+
if (existsSync(registryPath)) pushStore(stores, "registry", registryPath);
|
|
628
697
|
|
|
629
698
|
const bindingsPath = join(root, ".kxm", "runtime", "bindings.db");
|
|
630
|
-
if (existsSync(bindingsPath))
|
|
631
|
-
stores.push({ storeId: "binding-store", sourcePath: bindingsPath, maxSupportedVersion: kxmBackupCeiling("binding-store") });
|
|
632
|
-
}
|
|
699
|
+
if (existsSync(bindingsPath)) pushStore(stores, "binding-store", bindingsPath);
|
|
633
700
|
|
|
634
701
|
const eventsDir = join(root, ".kxm", "runtime", "events");
|
|
635
702
|
if (existsSync(eventsDir)) {
|
|
636
703
|
const entries = readdirSync(eventsDir, { withFileTypes: true });
|
|
637
704
|
for (const entry of entries) {
|
|
638
705
|
if (entry.isFile() && entry.name.endsWith(".db")) {
|
|
639
|
-
const key = entry.name.replace(/\.db$/, "");
|
|
640
|
-
stores.
|
|
641
|
-
storeId: `events:${key}`,
|
|
642
|
-
sourcePath: join(eventsDir, entry.name),
|
|
643
|
-
maxSupportedVersion: kxmBackupCeiling(`events:${key}`),
|
|
644
|
-
});
|
|
706
|
+
const key = entry.name.replace(/\.db$/, "").replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
707
|
+
pushStore(stores, `events:${key}`, join(eventsDir, entry.name));
|
|
645
708
|
}
|
|
646
709
|
}
|
|
647
710
|
}
|
|
648
711
|
|
|
649
|
-
|
|
712
|
+
discoverUserRuntime(options.env, stores, files);
|
|
713
|
+
return { stores, files };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function backupPlainFile(sourcePath: string, targetPath: string, id: string): BackupFileRecord {
|
|
717
|
+
const resolvedSource = resolve(sourcePath);
|
|
718
|
+
const sourceStat = lstatSync(resolvedSource, { throwIfNoEntry: false });
|
|
719
|
+
if (!sourceStat || !sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
|
720
|
+
throw databaseError("runtime_path_invalid", resolvedSource, `backup file ${resolvedSource} must be a regular file, not a link or directory`);
|
|
721
|
+
}
|
|
722
|
+
checkedParent(targetPath, "backup target");
|
|
723
|
+
if (existsSync(targetPath)) unlinkSync(targetPath);
|
|
724
|
+
copyFileSync(resolvedSource, targetPath);
|
|
725
|
+
try { chmodSync(targetPath, 0o600); } catch { /* Windows */ }
|
|
726
|
+
return {
|
|
727
|
+
id,
|
|
728
|
+
sourcePath: resolvedSource,
|
|
729
|
+
backupFile: basename(targetPath),
|
|
730
|
+
sha256: fileSha256(targetPath),
|
|
731
|
+
bytes: sourceStat.size,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function backupFilename(sourcePath: string, id: string, used: Set<string>): string {
|
|
736
|
+
let filename = basename(sourcePath);
|
|
737
|
+
if (used.has(filename)) {
|
|
738
|
+
filename = `${id.replace(/[^a-zA-Z0-9_.-]/g, "_")}-${filename}`;
|
|
739
|
+
}
|
|
740
|
+
used.add(filename);
|
|
741
|
+
return filename;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function restorePlainFile(backupFilePath: string, targetPath: string): void {
|
|
745
|
+
checkedParent(targetPath, "restore target");
|
|
746
|
+
if (existsSync(targetPath)) unlinkSync(targetPath);
|
|
747
|
+
copyFileSync(backupFilePath, targetPath);
|
|
748
|
+
try { chmodSync(targetPath, 0o600); } catch { /* Windows */ }
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export function discoverProjectStores(projectRoot: string, options: { hubDataPath?: string; env?: NodeJS.ProcessEnv } = {}): Array<{ storeId: string; sourcePath: string; maxSupportedVersion: number }> {
|
|
752
|
+
return discoverBackupSources(projectRoot, options).stores;
|
|
650
753
|
}
|
|
651
754
|
|
|
652
755
|
export interface BackupPlan {
|
|
@@ -654,21 +757,28 @@ export interface BackupPlan {
|
|
|
654
757
|
outDir: string;
|
|
655
758
|
createdAt: string;
|
|
656
759
|
stores: Array<{ storeId: string; sourcePath: string; backupFile: string }>;
|
|
760
|
+
files: Array<{ id: string; sourcePath: string; backupFile: string }>;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function backupDiscoverOptions(options: { hubDataPath?: string; env?: NodeJS.ProcessEnv }): { hubDataPath?: string; env?: NodeJS.ProcessEnv } {
|
|
764
|
+
return {
|
|
765
|
+
...(options.hubDataPath !== undefined ? { hubDataPath: options.hubDataPath } : {}),
|
|
766
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
767
|
+
};
|
|
657
768
|
}
|
|
658
769
|
|
|
659
|
-
/** Which stores a backup would copy and where, without opening any of them.
|
|
770
|
+
/** Which stores and files a backup would copy and where, without opening any of them.
|
|
660
771
|
* Opening a source for backup checkpoints its WAL, so the plan stays at paths. */
|
|
661
772
|
export function planBackup(options: {
|
|
662
773
|
projectRoot?: string;
|
|
663
774
|
outDir?: string;
|
|
664
775
|
hubDataPath?: string;
|
|
776
|
+
env?: NodeJS.ProcessEnv;
|
|
665
777
|
} = {}): BackupPlan {
|
|
666
778
|
const projectRoot = options.projectRoot ? resolve(options.projectRoot) : process.cwd();
|
|
667
|
-
const
|
|
668
|
-
...(options.hubDataPath !== undefined ? { hubDataPath: options.hubDataPath } : {}),
|
|
669
|
-
});
|
|
779
|
+
const discovered = discoverBackupSources(projectRoot, backupDiscoverOptions(options));
|
|
670
780
|
|
|
671
|
-
if (stores.length === 0) {
|
|
781
|
+
if (discovered.stores.length === 0) {
|
|
672
782
|
throw databaseError("backup_no_stores", projectRoot, "no existing SQLite stores found to backup");
|
|
673
783
|
}
|
|
674
784
|
|
|
@@ -676,24 +786,26 @@ export function planBackup(options: {
|
|
|
676
786
|
const timestamp = createdAt.replace(/[:.]/g, "-");
|
|
677
787
|
const outDir = options.outDir ? resolve(options.outDir) : join(projectRoot, ".kxm", "backups", `backup-${timestamp}`);
|
|
678
788
|
const usedFilenames = new Set<string>();
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
789
|
+
const stores = discovered.stores.map((store) => ({
|
|
790
|
+
storeId: store.storeId,
|
|
791
|
+
sourcePath: store.sourcePath,
|
|
792
|
+
backupFile: backupFilename(store.sourcePath, store.storeId, usedFilenames),
|
|
793
|
+
}));
|
|
794
|
+
const files = discovered.files.map((file) => ({
|
|
795
|
+
id: file.id,
|
|
796
|
+
sourcePath: file.sourcePath,
|
|
797
|
+
backupFile: backupFilename(file.sourcePath, file.id, usedFilenames),
|
|
798
|
+
}));
|
|
799
|
+
return { projectRoot, outDir, createdAt, stores, files };
|
|
689
800
|
}
|
|
690
801
|
|
|
691
802
|
export function createBackup(options: {
|
|
692
803
|
projectRoot?: string;
|
|
693
804
|
outDir?: string;
|
|
694
805
|
hubDataPath?: string;
|
|
806
|
+
env?: NodeJS.ProcessEnv;
|
|
695
807
|
} = {}): { manifest: BackupManifest; outDir: string } {
|
|
696
|
-
const { projectRoot, outDir, createdAt, stores } = planBackup(options);
|
|
808
|
+
const { projectRoot, outDir, createdAt, stores, files } = planBackup(options);
|
|
697
809
|
const backupId = `bk_${randomBytes(8).toString("hex")}`;
|
|
698
810
|
|
|
699
811
|
if (!existsSync(outDir)) {
|
|
@@ -701,8 +813,38 @@ export function createBackup(options: {
|
|
|
701
813
|
}
|
|
702
814
|
|
|
703
815
|
const backedUpStores: BackupStoreRecord[] = [];
|
|
816
|
+
const backedUpFiles: BackupFileRecord[] = [];
|
|
817
|
+
const omitted: string[] = [];
|
|
818
|
+
|
|
704
819
|
for (const store of stores) {
|
|
705
|
-
|
|
820
|
+
try {
|
|
821
|
+
backedUpStores.push(backupDatabaseFile(store.sourcePath, join(outDir, store.backupFile), store.storeId));
|
|
822
|
+
} catch {
|
|
823
|
+
omitted.push(store.storeId);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
for (const file of files) {
|
|
827
|
+
try {
|
|
828
|
+
backedUpFiles.push(backupPlainFile(file.sourcePath, join(outDir, file.backupFile), file.id));
|
|
829
|
+
} catch {
|
|
830
|
+
omitted.push(file.id);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (backedUpStores.length === 0) {
|
|
835
|
+
throw databaseError("backup_no_stores", projectRoot, "no SQLite store could be copied");
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const again = discoverBackupSources(projectRoot, backupDiscoverOptions(options));
|
|
839
|
+
for (const store of again.stores) {
|
|
840
|
+
if (!backedUpStores.some((copied) => copied.storeId === store.storeId) && !omitted.includes(store.storeId)) {
|
|
841
|
+
omitted.push(store.storeId);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
for (const file of again.files) {
|
|
845
|
+
if (!backedUpFiles.some((copied) => copied.id === file.id) && !omitted.includes(file.id)) {
|
|
846
|
+
omitted.push(file.id);
|
|
847
|
+
}
|
|
706
848
|
}
|
|
707
849
|
|
|
708
850
|
const manifest: BackupManifest = {
|
|
@@ -711,6 +853,9 @@ export function createBackup(options: {
|
|
|
711
853
|
createdAt,
|
|
712
854
|
projectRoot,
|
|
713
855
|
stores: backedUpStores,
|
|
856
|
+
...(backedUpFiles.length > 0 ? { files: backedUpFiles } : {}),
|
|
857
|
+
complete: omitted.length === 0,
|
|
858
|
+
...(omitted.length > 0 ? { omitted } : {}),
|
|
714
859
|
};
|
|
715
860
|
|
|
716
861
|
const manifestJson = JSON.stringify(manifest, null, 2) + "\n";
|
|
@@ -728,6 +873,7 @@ export interface RestorePlan {
|
|
|
728
873
|
manifestPath: string;
|
|
729
874
|
backupId: string;
|
|
730
875
|
stores: Array<{ storeId: string; backupFilePath: string; targetPath: string; schemaVersion: number; maxSupportedVersion: number }>;
|
|
876
|
+
files: Array<{ id: string; backupFilePath: string; targetPath: string }>;
|
|
731
877
|
}
|
|
732
878
|
|
|
733
879
|
/** Everything restore checks before it overwrites anything: the manifest, each
|
|
@@ -762,6 +908,9 @@ export function planRestore(
|
|
|
762
908
|
if (manifest.schema !== "kxm.backup-manifest.v1" || !Array.isArray(manifest.stores) || manifest.stores.length === 0) {
|
|
763
909
|
throw databaseError("restore_manifest_invalid", manifestPath, "manifest is not a valid kxm.backup-manifest.v1 document");
|
|
764
910
|
}
|
|
911
|
+
if (manifest.complete === false) {
|
|
912
|
+
throw databaseError("restore_incomplete", manifestPath, "backup manifest is incomplete; refusing to restore a partial copy");
|
|
913
|
+
}
|
|
765
914
|
|
|
766
915
|
const stores: RestorePlan["stores"] = [];
|
|
767
916
|
for (const store of manifest.stores) {
|
|
@@ -796,7 +945,29 @@ export function planRestore(
|
|
|
796
945
|
stores.push({ storeId: store.storeId, backupFilePath, targetPath, schemaVersion: store.schemaVersion, maxSupportedVersion });
|
|
797
946
|
}
|
|
798
947
|
|
|
799
|
-
|
|
948
|
+
const files: RestorePlan["files"] = [];
|
|
949
|
+
for (const file of manifest.files ?? []) {
|
|
950
|
+
const backupFilePath = join(manifestDir, file.backupFile);
|
|
951
|
+
if (!existsSync(backupFilePath)) {
|
|
952
|
+
throw databaseError("restore_file_missing", backupFilePath, `backup file ${file.backupFile} missing from ${manifestDir}`);
|
|
953
|
+
}
|
|
954
|
+
const actualSha256 = fileSha256(backupFilePath);
|
|
955
|
+
if (actualSha256 !== file.sha256) {
|
|
956
|
+
throw databaseError(
|
|
957
|
+
"restore_manifest_digest_mismatch",
|
|
958
|
+
backupFilePath,
|
|
959
|
+
`backup file ${file.backupFile} sha256 ${actualSha256} does not match manifest hash ${file.sha256}`,
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
let targetPath = file.sourcePath;
|
|
963
|
+
if (options.projectRoot && manifest.projectRoot && targetPath.startsWith(manifest.projectRoot)) {
|
|
964
|
+
const rel = targetPath.slice(manifest.projectRoot.length).replace(/^[\\/]+/, "");
|
|
965
|
+
targetPath = join(resolve(options.projectRoot), rel);
|
|
966
|
+
}
|
|
967
|
+
files.push({ id: file.id, backupFilePath, targetPath });
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
return { manifestPath, backupId: manifest.backupId, stores, files };
|
|
800
971
|
}
|
|
801
972
|
|
|
802
973
|
export function restoreBackup(
|
|
@@ -811,6 +982,9 @@ export function restoreBackup(
|
|
|
811
982
|
store.schemaVersion,
|
|
812
983
|
store.maxSupportedVersion,
|
|
813
984
|
));
|
|
985
|
+
for (const file of plan.files) {
|
|
986
|
+
restorePlainFile(file.backupFilePath, file.targetPath);
|
|
987
|
+
}
|
|
814
988
|
return {
|
|
815
989
|
manifestPath: plan.manifestPath,
|
|
816
990
|
backupId: plan.backupId,
|
|
@@ -3,6 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { parse } from "yaml";
|
|
5
5
|
import { isRouteAdmitted, listRoleBindings } from "./routes.ts";
|
|
6
|
+
import { oneShotWriterArgs } from "./harness.ts";
|
|
7
|
+
import { applyAuthoringWitness, captureWorktreeWitness } from "./worktree-witness.ts";
|
|
6
8
|
import {
|
|
7
9
|
buildFormalContextPacket,
|
|
8
10
|
buildHandoffManifest,
|
|
@@ -153,6 +155,8 @@ export interface KxmProducerRequest {
|
|
|
153
155
|
readonly thinking?: string | undefined;
|
|
154
156
|
readonly agentRole?: string | undefined;
|
|
155
157
|
readonly harness?: string | undefined;
|
|
158
|
+
/** Live producers select an audited argv profile from this ceiling. */
|
|
159
|
+
readonly permission?: "read-only" | "edit" | undefined;
|
|
156
160
|
readonly contextPacket?: FormalContextPacketV2 | undefined;
|
|
157
161
|
readonly handoffManifest?: HandoffManifestV1 | undefined;
|
|
158
162
|
}
|
|
@@ -1593,6 +1597,14 @@ function prepareDispatch(
|
|
|
1593
1597
|
return { kind: "return", state, handoff: { ...routeResult.error, stepId } };
|
|
1594
1598
|
}
|
|
1595
1599
|
resolvedRoute = routeResult;
|
|
1600
|
+
const writeRefusal = unsupportedLiveWrite(
|
|
1601
|
+
context.projectRoot,
|
|
1602
|
+
step,
|
|
1603
|
+
agentId,
|
|
1604
|
+
resolvedRoute.selector,
|
|
1605
|
+
loadKxmRunPlanEnvelope(context.eventStore, run).projectLimits.maxConcurrentRuns,
|
|
1606
|
+
);
|
|
1607
|
+
if (writeRefusal) return { kind: "return", state, handoff: { ...writeRefusal, stepId } };
|
|
1596
1608
|
}
|
|
1597
1609
|
|
|
1598
1610
|
const used = state.stepAttempts[stepId] ?? 0;
|
|
@@ -1825,6 +1837,7 @@ function birthMember(
|
|
|
1825
1837
|
signal: controller.signal,
|
|
1826
1838
|
prompt: input.step.instructions ? `${input.step.instructions}\n\n${generatedPrompt}` : generatedPrompt,
|
|
1827
1839
|
thinking: input.stepAttempt <= 1 ? "low" : "medium",
|
|
1840
|
+
permission: Object.values(input.step.repositories).some((access) => access === "write") ? "edit" : "read-only",
|
|
1828
1841
|
contextPacket,
|
|
1829
1842
|
...(resolvedRoute ? { provider: resolvedRoute.provider, model: resolvedRoute.model } : {}),
|
|
1830
1843
|
},
|
|
@@ -1998,7 +2011,18 @@ async function drivePanel(
|
|
|
1998
2011
|
if (!executingBound()) return { attemptId: member.attemptId, invoked: false, skipped: true };
|
|
1999
2012
|
kxmPanelDispatchSeams.beforeInvoke?.(member);
|
|
2000
2013
|
if (!executingBound()) return { attemptId: member.attemptId, invoked: false, skipped: true };
|
|
2014
|
+
const live = member.producerId !== "driver-simulated";
|
|
2015
|
+
const writes = Object.values(member.step.repositories).some((access) => access === "write");
|
|
2016
|
+
const before = live ? captureWorktreeWitness(context.projectRoot) : undefined;
|
|
2001
2017
|
const produced = await invokeProducer(producer, member.request);
|
|
2018
|
+
if (live && produced.result && before) {
|
|
2019
|
+
const after = captureWorktreeWitness(context.projectRoot);
|
|
2020
|
+
return {
|
|
2021
|
+
attemptId: member.attemptId,
|
|
2022
|
+
invoked: true,
|
|
2023
|
+
produced: { ...produced, result: applyAuthoringWitness(produced.result, { writes, before, after }) },
|
|
2024
|
+
};
|
|
2025
|
+
}
|
|
2002
2026
|
return { attemptId: member.attemptId, invoked: true, produced };
|
|
2003
2027
|
} catch (error) {
|
|
2004
2028
|
member.controller.abort();
|
|
@@ -2722,11 +2746,102 @@ function unsupportedStep(
|
|
|
2722
2746
|
return { reason: "step_unsupported", field: "repositories", detail: `invalid repository access '${access}' on ${repoId}` };
|
|
2723
2747
|
}
|
|
2724
2748
|
}
|
|
2725
|
-
|
|
2749
|
+
return undefined;
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
function readYamlRecord(path: string): Record<string, unknown> | undefined {
|
|
2753
|
+
if (!existsSync(path)) return undefined;
|
|
2754
|
+
try {
|
|
2755
|
+
const parsed = parse(readFileSync(path, "utf8")) as unknown;
|
|
2756
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
|
|
2757
|
+
} catch {
|
|
2758
|
+
return undefined;
|
|
2759
|
+
}
|
|
2760
|
+
return undefined;
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
function agentHarness(projectRoot: string, agentId: string): string | undefined {
|
|
2764
|
+
const harness = readYamlRecord(join(projectRoot, ".kxm", "agents", `${agentId}.yaml`))?.harness;
|
|
2765
|
+
return typeof harness === "string" && harness.length > 0 ? harness : undefined;
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
function projectDefaultHarness(projectRoot: string): string {
|
|
2769
|
+
const harness = readYamlRecord(join(projectRoot, ".kxm", "project.yaml"))?.defaultHarness;
|
|
2770
|
+
return typeof harness === "string" && harness.length > 0 ? harness : "pi";
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
/**
|
|
2774
|
+
* Live write steps run only on an audited writer profile, and only when the
|
|
2775
|
+
* developer roster (when present) lists that harness and model as an edit writer.
|
|
2776
|
+
* A missing roster is a fresh project: route admission is the other gate.
|
|
2777
|
+
*
|
|
2778
|
+
* The authoring witness fingerprints the one project checkout around each
|
|
2779
|
+
* spawn, so it can only attribute a change to a lone writer. A write step with
|
|
2780
|
+
* more than one assignment would run several writers there (and member n runs
|
|
2781
|
+
* as allowedAgents[n], which the writer check below never sees), and a
|
|
2782
|
+
* project that admits concurrent runs lets another run's writer edit the tree
|
|
2783
|
+
* mid-attempt. Both hand off.
|
|
2784
|
+
*/
|
|
2785
|
+
function unsupportedLiveWrite(
|
|
2786
|
+
projectRoot: string,
|
|
2787
|
+
step: KxmCompiledStep,
|
|
2788
|
+
agentId: string,
|
|
2789
|
+
selector: string,
|
|
2790
|
+
maxConcurrentRuns: number,
|
|
2791
|
+
): Omit<KxmRunHandoff, "stepId"> | undefined {
|
|
2792
|
+
if (!Object.values(step.repositories).some((access) => access === "write")) return undefined;
|
|
2793
|
+
if (step.assignments.maximum !== 1) {
|
|
2794
|
+
return {
|
|
2795
|
+
reason: "step_unsupported",
|
|
2796
|
+
field: "assignments.maximum",
|
|
2797
|
+
detail: "live write steps run a single assignment; the checkout witness cannot attribute edits between writers",
|
|
2798
|
+
};
|
|
2799
|
+
}
|
|
2800
|
+
if (maxConcurrentRuns !== 1) {
|
|
2801
|
+
return {
|
|
2802
|
+
reason: "step_unsupported",
|
|
2803
|
+
field: "limits.maxConcurrentRuns",
|
|
2804
|
+
detail: "live write steps require limits.maxConcurrentRuns of 1; concurrent runs share one checkout",
|
|
2805
|
+
};
|
|
2806
|
+
}
|
|
2807
|
+
const harness = agentHarness(projectRoot, agentId) ?? projectDefaultHarness(projectRoot);
|
|
2808
|
+
if (!oneShotWriterArgs(harness)) {
|
|
2726
2809
|
return {
|
|
2727
2810
|
reason: "step_unsupported",
|
|
2728
2811
|
field: "repositories",
|
|
2729
|
-
detail:
|
|
2812
|
+
detail: `live write steps require an audited writer profile; ${harness} has none`,
|
|
2813
|
+
};
|
|
2814
|
+
}
|
|
2815
|
+
const rosterPath = join(projectRoot, ".kxm", "roster.yaml");
|
|
2816
|
+
if (!existsSync(rosterPath)) return undefined;
|
|
2817
|
+
const roster = readYamlRecord(rosterPath);
|
|
2818
|
+
if (!roster || roster.schema !== "kxm.developer-roster.v1") {
|
|
2819
|
+
return { reason: "step_unsupported", field: "model", detail: "live write steps require a readable kxm.developer-roster.v1" };
|
|
2820
|
+
}
|
|
2821
|
+
const routes = roster.routes;
|
|
2822
|
+
const lineup = roster.lineup;
|
|
2823
|
+
const writerIds = lineup && typeof lineup === "object" && !Array.isArray(lineup)
|
|
2824
|
+
? (lineup as Record<string, unknown>).writer
|
|
2825
|
+
: undefined;
|
|
2826
|
+
if (!routes || typeof routes !== "object" || Array.isArray(routes) || !Array.isArray(writerIds)) {
|
|
2827
|
+
return { reason: "step_unsupported", field: "model", detail: "developer roster has no writer lineup" };
|
|
2828
|
+
}
|
|
2829
|
+
const allowed = writerIds.some((id) => {
|
|
2830
|
+
if (typeof id !== "string") return false;
|
|
2831
|
+
const route = (routes as Record<string, unknown>)[id];
|
|
2832
|
+
if (!route || typeof route !== "object" || Array.isArray(route)) return false;
|
|
2833
|
+
const record = route as Record<string, unknown>;
|
|
2834
|
+
if (record.harness !== harness || record.status !== "admitted") return false;
|
|
2835
|
+
if (!Array.isArray(record.permissions) || !record.permissions.includes("edit")) return false;
|
|
2836
|
+
const model = typeof record.model === "string" ? record.model : "";
|
|
2837
|
+
const vendor = typeof record.vendor === "string" ? record.vendor : "";
|
|
2838
|
+
return model === selector || (vendor.length > 0 && `${vendor}/${model}` === selector);
|
|
2839
|
+
});
|
|
2840
|
+
if (!allowed) {
|
|
2841
|
+
return {
|
|
2842
|
+
reason: "step_unsupported",
|
|
2843
|
+
field: "model",
|
|
2844
|
+
detail: `live write route ${selector} on ${harness} is not on the developer roster writer lineup`,
|
|
2730
2845
|
};
|
|
2731
2846
|
}
|
|
2732
2847
|
return undefined;
|
|
@@ -898,6 +898,7 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
898
898
|
throw new Error(`tool_policy_denied: ${policy.detail ?? policy.error}`);
|
|
899
899
|
}
|
|
900
900
|
// A request sent while handling the active inbound request continues its hop chain.
|
|
901
|
+
// No inbox and no hubInbox: `pending` owns activation, so kxm_inbox refuses here.
|
|
901
902
|
const handling = activeInbound ? [activeInbound] : [];
|
|
902
903
|
return result(
|
|
903
904
|
await workflowCall(() =>
|
|
@@ -518,6 +518,35 @@ export function oneShotReadOnlyArgs(harness: string): readonly string[] | undefi
|
|
|
518
518
|
return Object.hasOwn(READ_ONLY_ONESHOT_ARGS, harness) ? READ_ONLY_ONESHOT_ARGS[harness as keyof typeof READ_ONLY_ONESHOT_ARGS] : undefined;
|
|
519
519
|
}
|
|
520
520
|
|
|
521
|
+
/**
|
|
522
|
+
* Audited edit profiles. These are a writer containment, not read-only flags
|
|
523
|
+
* with the sandbox removed. They match the assignment helper
|
|
524
|
+
* (`scripts/harness-run.mjs` `buildArgv` for `permission: "edit"` with hooks
|
|
525
|
+
* and skills refused):
|
|
526
|
+
*
|
|
527
|
+
* - Pi: `-a` auto-approves tools so the process can edit the checkout, while
|
|
528
|
+
* extensions, skills, prompt templates, and session persistence stay off.
|
|
529
|
+
* `--no-tools` is the read-only profile and is not used here.
|
|
530
|
+
* - Grok: `--always-approve` so edits are not an interactive prompt, with
|
|
531
|
+
* subagents and web search disabled. Grok has no audited read-only edit
|
|
532
|
+
* mix; the read-only one-shot flags stay on the read-only profile.
|
|
533
|
+
*
|
|
534
|
+
* Claude, Codex, agy, and Kimi stay read-only. A live write step on a harness
|
|
535
|
+
* without an entry here hands off instead of spawning unconstrained.
|
|
536
|
+
*/
|
|
537
|
+
const WRITER_ONESHOT_ARGS = Object.freeze({
|
|
538
|
+
pi: Object.freeze(["-a", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-session"]),
|
|
539
|
+
grok: Object.freeze(["--always-approve", "--no-subagents", "--disable-web-search"]),
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
export function oneShotWriterArgs(harness: string): readonly string[] | undefined {
|
|
543
|
+
return Object.hasOwn(WRITER_ONESHOT_ARGS, harness) ? WRITER_ONESHOT_ARGS[harness as keyof typeof WRITER_ONESHOT_ARGS] : undefined;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function oneShotPermissionArgs(harness: string, permission: "read-only" | "edit"): readonly string[] | undefined {
|
|
547
|
+
return permission === "edit" ? oneShotWriterArgs(harness) : oneShotReadOnlyArgs(harness);
|
|
548
|
+
}
|
|
549
|
+
|
|
521
550
|
export const BUILTIN_HARNESSES: readonly HarnessCatalogEntry[] = Object.freeze([
|
|
522
551
|
{
|
|
523
552
|
id: "pi",
|
package/plugins/kxm/src/hub.ts
CHANGED
|
@@ -2520,6 +2520,18 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
2520
2520
|
return;
|
|
2521
2521
|
}
|
|
2522
2522
|
|
|
2523
|
+
// An agent's open inbound requests, queued or delivered, in delivery order. A read
|
|
2524
|
+
// for a caller that holds no event-fed inbox of its own (a one-shot CLI call): it
|
|
2525
|
+
// acknowledges nothing and moves no cursor, so push delivery is unchanged.
|
|
2526
|
+
const inboxMatch = url.pathname.match(/^\/v1\/agents\/([^/]+)\/inbox$/);
|
|
2527
|
+
if (method === "GET" && inboxMatch) {
|
|
2528
|
+
const current = requireAgent(request, decodeURIComponent(inboxMatch[1]!));
|
|
2529
|
+
requireProjectAuth(request, current.project);
|
|
2530
|
+
expireMessages();
|
|
2531
|
+
json(response, 200, { messages: store.getPendingMessages(current.id) });
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2523
2535
|
const agentMatch = url.pathname.match(/^\/v1\/agents\/([^/]+)$/);
|
|
2524
2536
|
if (method === "DELETE" && agentMatch) {
|
|
2525
2537
|
const current = requireAgent(request, decodeURIComponent(agentMatch[1]!));
|