@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.
Files changed (53) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/workflows/default.yaml +2 -0
  3. package/CHANGELOG.md +46 -2
  4. package/docs/concepts/architecture.md +1 -1
  5. package/docs/concepts/data-and-storage.md +1 -1
  6. package/docs/contracts/routing.md +1 -1
  7. package/docs/contributing/test-matrix.md +6 -5
  8. package/docs/guides/peer-messaging.md +2 -2
  9. package/docs/operations/backup-and-restore.md +43 -24
  10. package/docs/operations/deploy.md +1 -1
  11. package/docs/reference/cli-reference.md +65 -28
  12. package/docs/reference/config-reference.md +24 -13
  13. package/docs/reference/harness-routing.md +3 -3
  14. package/docs/reference/http-api.md +2 -1
  15. package/docs/reference/tools.md +3 -3
  16. package/docs/start/first-workflow.md +4 -4
  17. package/docs/start/quickstart-claude-code.md +2 -2
  18. package/package.json +1 -1
  19. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  20. package/plugins/kxm/dist/claude-hook.js +4 -1
  21. package/plugins/kxm/dist/cli.js +407 -156
  22. package/plugins/kxm/dist/client.js +9 -0
  23. package/plugins/kxm/dist/core.js +9 -3
  24. package/plugins/kxm/dist/extension.js +13 -1
  25. package/plugins/kxm/dist/mcp-server.js +14 -2
  26. package/plugins/kxm/dist/runtime-supervisor.js +231 -48
  27. package/plugins/kxm/dist/runtime.js +415 -92
  28. package/plugins/kxm/dist/server.js +19 -1
  29. package/plugins/kxm/package.json +1 -1
  30. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +4 -3
  31. package/plugins/kxm/skills/kxm-peer/SKILL.md +5 -3
  32. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +3 -2
  33. package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +3 -0
  34. package/plugins/kxm/skills/kxm-runs/SKILL.md +8 -7
  35. package/plugins/kxm/src/cli/project.ts +22 -13
  36. package/plugins/kxm/src/cli/system.ts +22 -1
  37. package/plugins/kxm/src/cli.ts +14 -4
  38. package/plugins/kxm/src/client.ts +10 -0
  39. package/plugins/kxm/src/commands.ts +10 -1
  40. package/plugins/kxm/src/database.ts +210 -36
  41. package/plugins/kxm/src/engine.ts +117 -2
  42. package/plugins/kxm/src/extension.ts +1 -0
  43. package/plugins/kxm/src/harness.ts +29 -0
  44. package/plugins/kxm/src/hub.ts +12 -0
  45. package/plugins/kxm/src/init-guide-setup.ts +43 -28
  46. package/plugins/kxm/src/mcp-server.ts +1 -1
  47. package/plugins/kxm/src/oneshot-producer.ts +16 -8
  48. package/plugins/kxm/src/prices.ts +33 -2
  49. package/plugins/kxm/src/routing.ts +13 -7
  50. package/plugins/kxm/src/studio-layout.ts +5 -4
  51. package/plugins/kxm/src/template.ts +31 -0
  52. package/plugins/kxm/src/worktree-witness.ts +71 -0
  53. 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
- export function discoverProjectStores(projectRoot: string, options: { hubDataPath?: string } = {}): Array<{ storeId: string; sourcePath: string; maxSupportedVersion: number }> {
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: Array<{ storeId: string; sourcePath: string; maxSupportedVersion: number }> = [];
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.push({
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
- return stores;
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 stores = discoverProjectStores(projectRoot, {
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 planned = stores.map((store) => {
680
- let filename = basename(store.sourcePath);
681
- if (usedFilenames.has(filename)) {
682
- const sanitizedId = store.storeId.replace(/[^a-zA-Z0-9_.-]/g, "_");
683
- filename = `${sanitizedId}-${filename}`;
684
- }
685
- usedFilenames.add(filename);
686
- return { storeId: store.storeId, sourcePath: store.sourcePath, backupFile: filename };
687
- });
688
- return { projectRoot, outDir, createdAt, stores: planned };
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
- backedUpStores.push(backupDatabaseFile(store.sourcePath, join(outDir, store.backupFile), store.storeId));
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
- return { manifestPath, backupId: manifest.backupId, stores };
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
- if (producerId !== "driver-simulated" && Object.values(step.repositories).some((access) => access === "write")) {
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: "live write steps are unsupported until writer sandboxing witness passes",
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",
@@ -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]!));