@kontextmind/kxm 0.7.51 → 0.7.53

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.
@@ -17121,7 +17121,7 @@ async function deliverInboxNotification(messageId, delivered, notify) {
17121
17121
  }
17122
17122
 
17123
17123
  // plugins/kxm/src/mcp-server.ts
17124
- var VERSION = "0.7.51";
17124
+ var VERSION = "0.7.53";
17125
17125
  var inbox = /* @__PURE__ */ new Map();
17126
17126
  var notifiedInbox = /* @__PURE__ */ new Set();
17127
17127
  var meshClient;
@@ -17612,20 +17612,11 @@ function openDatabase(file, description, spec) {
17612
17612
  database.exec(spec.schema);
17613
17613
  database.exec(`PRAGMA user_version = ${spec.version}`);
17614
17614
  } else if (version < spec.version) {
17615
- let currentVersion = version;
17616
- while (currentVersion < spec.version) {
17617
- const step = spec.migrations?.find((m) => m.fromVersion === currentVersion);
17618
- if (!step) {
17619
- throw databaseError(
17620
- "runtime_schema_outdated",
17621
- file,
17622
- `${description} schema version ${version} is older than ${spec.version}; no migration lane, backup and restore remain E6`
17623
- );
17624
- }
17625
- step.migrate(database);
17626
- currentVersion = step.toVersion;
17627
- database.exec(`PRAGMA user_version = ${currentVersion}`);
17628
- }
17615
+ throw databaseError(
17616
+ "runtime_schema_outdated",
17617
+ file,
17618
+ `${description} is schema version ${version}; this build requires ${spec.version}. Delete the state file (or re-run \`kxm init\`) to start fresh \u2014 upgrading old state in place is deliberately unsupported`
17619
+ );
17629
17620
  }
17630
17621
  if (spec.tables) {
17631
17622
  verifyExpectedTables(database, file, description, spec.tables);
@@ -18270,71 +18261,6 @@ CREATE TABLE project_controls (
18270
18261
  record TEXT NOT NULL
18271
18262
  ) STRICT;
18272
18263
  `;
18273
- var COORDINATOR_INTAKE_DDL = `
18274
- CREATE TABLE IF NOT EXISTS coordinators (
18275
- coordinator_id TEXT PRIMARY KEY,
18276
- project_id TEXT NOT NULL,
18277
- role TEXT NOT NULL,
18278
- channel TEXT NOT NULL,
18279
- ceiling_hash TEXT NOT NULL,
18280
- config_revision TEXT NOT NULL,
18281
- bound_at TEXT NOT NULL,
18282
- schema TEXT NOT NULL,
18283
- record TEXT NOT NULL
18284
- ) STRICT;
18285
- CREATE UNIQUE INDEX IF NOT EXISTS coordinators_slot ON coordinators(project_id, role, channel);
18286
- CREATE TABLE IF NOT EXISTS intake_messages (
18287
- message_id TEXT PRIMARY KEY,
18288
- project_id TEXT NOT NULL,
18289
- coordinator_id TEXT NOT NULL,
18290
- idempotency_key TEXT NOT NULL,
18291
- content_hash TEXT NOT NULL,
18292
- received_at TEXT NOT NULL,
18293
- dispatch_state TEXT NOT NULL CHECK (dispatch_state IN ('ready','held_paused','admitted','refused')),
18294
- schema TEXT NOT NULL,
18295
- record TEXT NOT NULL
18296
- ) STRICT;
18297
- CREATE UNIQUE INDEX IF NOT EXISTS intake_idempotency ON intake_messages(project_id, coordinator_id, idempotency_key);
18298
- CREATE INDEX IF NOT EXISTS intake_dispatch ON intake_messages(project_id, dispatch_state, received_at, message_id);
18299
- CREATE TABLE IF NOT EXISTS project_controls (
18300
- project_id TEXT PRIMARY KEY,
18301
- paused INTEGER NOT NULL CHECK (paused IN (0, 1)),
18302
- reason TEXT,
18303
- updated_at TEXT NOT NULL,
18304
- actor TEXT NOT NULL,
18305
- schema TEXT NOT NULL,
18306
- record TEXT NOT NULL
18307
- ) STRICT;
18308
- `;
18309
- var DRIVE_RECEIPTS_DDL = `
18310
- CREATE TABLE IF NOT EXISTS drive_receipts (
18311
- drive_id TEXT PRIMARY KEY,
18312
- run_id TEXT NOT NULL,
18313
- project_id TEXT NOT NULL,
18314
- opened_sequence INTEGER NOT NULL,
18315
- last_sequence INTEGER NOT NULL,
18316
- closed_at TEXT NOT NULL,
18317
- schema TEXT NOT NULL,
18318
- receipt TEXT NOT NULL
18319
- ) STRICT;
18320
- CREATE INDEX IF NOT EXISTS drive_receipts_run ON drive_receipts(run_id);
18321
- `;
18322
- var EVENT_STORE_MIGRATIONS = [
18323
- {
18324
- fromVersion: 3,
18325
- toVersion: 4,
18326
- migrate(database) {
18327
- database.exec(DRIVE_RECEIPTS_DDL);
18328
- }
18329
- },
18330
- {
18331
- fromVersion: 4,
18332
- toVersion: 5,
18333
- migrate(database) {
18334
- database.exec(COORDINATOR_INTAKE_DDL);
18335
- }
18336
- }
18337
- ];
18338
18264
  var KxmRunEventStore = class {
18339
18265
  path;
18340
18266
  database;
@@ -18343,8 +18269,7 @@ var KxmRunEventStore = class {
18343
18269
  this.database = openDatabase(this.path, "run event store", {
18344
18270
  schema: EVENT_STORE_SCHEMA,
18345
18271
  version: KXM_EVENT_STORE_SCHEMA_VERSION,
18346
- tables: EVENT_STORE_TABLES,
18347
- migrations: EVENT_STORE_MIGRATIONS
18272
+ tables: EVENT_STORE_TABLES
18348
18273
  });
18349
18274
  }
18350
18275
  close() {
@@ -17783,20 +17783,11 @@ function openDatabase(file, description, spec) {
17783
17783
  database.exec(spec.schema);
17784
17784
  database.exec(`PRAGMA user_version = ${spec.version}`);
17785
17785
  } else if (version < spec.version) {
17786
- let currentVersion = version;
17787
- while (currentVersion < spec.version) {
17788
- const step = spec.migrations?.find((m) => m.fromVersion === currentVersion);
17789
- if (!step) {
17790
- throw databaseError(
17791
- "runtime_schema_outdated",
17792
- file,
17793
- `${description} schema version ${version} is older than ${spec.version}; no migration lane, backup and restore remain E6`
17794
- );
17795
- }
17796
- step.migrate(database);
17797
- currentVersion = step.toVersion;
17798
- database.exec(`PRAGMA user_version = ${currentVersion}`);
17799
- }
17786
+ throw databaseError(
17787
+ "runtime_schema_outdated",
17788
+ file,
17789
+ `${description} is schema version ${version}; this build requires ${spec.version}. Delete the state file (or re-run \`kxm init\`) to start fresh \u2014 upgrading old state in place is deliberately unsupported`
17790
+ );
17800
17791
  }
17801
17792
  if (spec.tables) {
17802
17793
  verifyExpectedTables(database, file, description, spec.tables);
@@ -18710,71 +18701,6 @@ CREATE TABLE project_controls (
18710
18701
  record TEXT NOT NULL
18711
18702
  ) STRICT;
18712
18703
  `;
18713
- var COORDINATOR_INTAKE_DDL = `
18714
- CREATE TABLE IF NOT EXISTS coordinators (
18715
- coordinator_id TEXT PRIMARY KEY,
18716
- project_id TEXT NOT NULL,
18717
- role TEXT NOT NULL,
18718
- channel TEXT NOT NULL,
18719
- ceiling_hash TEXT NOT NULL,
18720
- config_revision TEXT NOT NULL,
18721
- bound_at TEXT NOT NULL,
18722
- schema TEXT NOT NULL,
18723
- record TEXT NOT NULL
18724
- ) STRICT;
18725
- CREATE UNIQUE INDEX IF NOT EXISTS coordinators_slot ON coordinators(project_id, role, channel);
18726
- CREATE TABLE IF NOT EXISTS intake_messages (
18727
- message_id TEXT PRIMARY KEY,
18728
- project_id TEXT NOT NULL,
18729
- coordinator_id TEXT NOT NULL,
18730
- idempotency_key TEXT NOT NULL,
18731
- content_hash TEXT NOT NULL,
18732
- received_at TEXT NOT NULL,
18733
- dispatch_state TEXT NOT NULL CHECK (dispatch_state IN ('ready','held_paused','admitted','refused')),
18734
- schema TEXT NOT NULL,
18735
- record TEXT NOT NULL
18736
- ) STRICT;
18737
- CREATE UNIQUE INDEX IF NOT EXISTS intake_idempotency ON intake_messages(project_id, coordinator_id, idempotency_key);
18738
- CREATE INDEX IF NOT EXISTS intake_dispatch ON intake_messages(project_id, dispatch_state, received_at, message_id);
18739
- CREATE TABLE IF NOT EXISTS project_controls (
18740
- project_id TEXT PRIMARY KEY,
18741
- paused INTEGER NOT NULL CHECK (paused IN (0, 1)),
18742
- reason TEXT,
18743
- updated_at TEXT NOT NULL,
18744
- actor TEXT NOT NULL,
18745
- schema TEXT NOT NULL,
18746
- record TEXT NOT NULL
18747
- ) STRICT;
18748
- `;
18749
- var DRIVE_RECEIPTS_DDL = `
18750
- CREATE TABLE IF NOT EXISTS drive_receipts (
18751
- drive_id TEXT PRIMARY KEY,
18752
- run_id TEXT NOT NULL,
18753
- project_id TEXT NOT NULL,
18754
- opened_sequence INTEGER NOT NULL,
18755
- last_sequence INTEGER NOT NULL,
18756
- closed_at TEXT NOT NULL,
18757
- schema TEXT NOT NULL,
18758
- receipt TEXT NOT NULL
18759
- ) STRICT;
18760
- CREATE INDEX IF NOT EXISTS drive_receipts_run ON drive_receipts(run_id);
18761
- `;
18762
- var EVENT_STORE_MIGRATIONS = [
18763
- {
18764
- fromVersion: 3,
18765
- toVersion: 4,
18766
- migrate(database) {
18767
- database.exec(DRIVE_RECEIPTS_DDL);
18768
- }
18769
- },
18770
- {
18771
- fromVersion: 4,
18772
- toVersion: 5,
18773
- migrate(database) {
18774
- database.exec(COORDINATOR_INTAKE_DDL);
18775
- }
18776
- }
18777
- ];
18778
18704
  var KxmRunEventStore = class {
18779
18705
  path;
18780
18706
  database;
@@ -18783,8 +18709,7 @@ var KxmRunEventStore = class {
18783
18709
  this.database = openDatabase(this.path, "run event store", {
18784
18710
  schema: EVENT_STORE_SCHEMA,
18785
18711
  version: KXM_EVENT_STORE_SCHEMA_VERSION,
18786
- tables: EVENT_STORE_TABLES,
18787
- migrations: EVENT_STORE_MIGRATIONS
18712
+ tables: EVENT_STORE_TABLES
18788
18713
  });
18789
18714
  }
18790
18715
  close() {
@@ -11504,20 +11504,11 @@ function openDatabase(file, description, spec) {
11504
11504
  database.exec(spec.schema);
11505
11505
  database.exec(`PRAGMA user_version = ${spec.version}`);
11506
11506
  } else if (version < spec.version) {
11507
- let currentVersion = version;
11508
- while (currentVersion < spec.version) {
11509
- const step = spec.migrations?.find((m) => m.fromVersion === currentVersion);
11510
- if (!step) {
11511
- throw databaseError(
11512
- "runtime_schema_outdated",
11513
- file,
11514
- `${description} schema version ${version} is older than ${spec.version}; no migration lane, backup and restore remain E6`
11515
- );
11516
- }
11517
- step.migrate(database);
11518
- currentVersion = step.toVersion;
11519
- database.exec(`PRAGMA user_version = ${currentVersion}`);
11520
- }
11507
+ throw databaseError(
11508
+ "runtime_schema_outdated",
11509
+ file,
11510
+ `${description} is schema version ${version}; this build requires ${spec.version}. Delete the state file (or re-run \`kxm init\`) to start fresh \u2014 upgrading old state in place is deliberately unsupported`
11511
+ );
11521
11512
  }
11522
11513
  if (spec.tables) {
11523
11514
  verifyExpectedTables(database, file, description, spec.tables);
@@ -11683,100 +11674,10 @@ var HUB_STORE_SCHEMA_V3 = `
11683
11674
  ) STRICT;
11684
11675
  CREATE INDEX IF NOT EXISTS context_items_project ON context_items(project);
11685
11676
  `;
11686
- var HUB_STORE_MIGRATIONS = Object.freeze([
11687
- {
11688
- fromVersion: 1,
11689
- toVersion: 2,
11690
- migrate: (db) => {
11691
- db.exec(`
11692
- CREATE TABLE IF NOT EXISTS agents (
11693
- id TEXT PRIMARY KEY,
11694
- record TEXT NOT NULL
11695
- ) STRICT;
11696
- CREATE TABLE IF NOT EXISTS messages (
11697
- id TEXT PRIMARY KEY,
11698
- record TEXT NOT NULL
11699
- ) STRICT;
11700
- CREATE TABLE IF NOT EXISTS workflow_runs (
11701
- id TEXT PRIMARY KEY,
11702
- definition_id TEXT NOT NULL,
11703
- delivery_id TEXT NOT NULL,
11704
- record TEXT NOT NULL,
11705
- UNIQUE(definition_id, delivery_id)
11706
- ) STRICT;
11707
- CREATE TABLE IF NOT EXISTS workflow_journal (
11708
- id TEXT PRIMARY KEY,
11709
- run_id TEXT NOT NULL,
11710
- category TEXT NOT NULL,
11711
- area TEXT NOT NULL,
11712
- record TEXT NOT NULL
11713
- ) STRICT;
11714
- CREATE INDEX IF NOT EXISTS workflow_journal_run_id ON workflow_journal(run_id);
11715
- `);
11716
- }
11717
- },
11718
- {
11719
- fromVersion: 2,
11720
- toVersion: 3,
11721
- migrate: (db) => {
11722
- db.exec(`
11723
- CREATE TABLE IF NOT EXISTS agents (
11724
- id TEXT PRIMARY KEY,
11725
- record TEXT NOT NULL
11726
- ) STRICT;
11727
- CREATE TABLE IF NOT EXISTS messages (
11728
- id TEXT PRIMARY KEY,
11729
- record TEXT NOT NULL
11730
- ) STRICT;
11731
- CREATE TABLE IF NOT EXISTS workflow_runs (
11732
- id TEXT PRIMARY KEY,
11733
- definition_id TEXT NOT NULL,
11734
- delivery_id TEXT NOT NULL,
11735
- record TEXT NOT NULL,
11736
- UNIQUE(definition_id, delivery_id)
11737
- ) STRICT;
11738
- CREATE TABLE IF NOT EXISTS workflow_journal (
11739
- id TEXT PRIMARY KEY,
11740
- run_id TEXT NOT NULL,
11741
- category TEXT NOT NULL,
11742
- area TEXT NOT NULL,
11743
- record TEXT NOT NULL
11744
- ) STRICT;
11745
- CREATE INDEX IF NOT EXISTS workflow_journal_run_id ON workflow_journal(run_id);
11746
- CREATE TABLE IF NOT EXISTS context_items (
11747
- id TEXT PRIMARY KEY,
11748
- project TEXT NOT NULL,
11749
- kind TEXT NOT NULL,
11750
- record TEXT NOT NULL
11751
- ) STRICT;
11752
- CREATE INDEX IF NOT EXISTS context_items_project ON context_items(project);
11753
- CREATE UNIQUE INDEX IF NOT EXISTS messages_from_idempotency
11754
- ON messages(
11755
- json_extract(record, '$.from'),
11756
- json_extract(record, '$.idempotencyKey')
11757
- ) WHERE json_extract(record, '$.idempotencyKey') IS NOT NULL;
11758
- CREATE INDEX IF NOT EXISTS messages_to_seq
11759
- ON messages(
11760
- json_extract(record, '$.to'),
11761
- COALESCE(json_extract(record, '$.seq'), 0)
11762
- );
11763
- CREATE TABLE IF NOT EXISTS consumer_cursors (
11764
- agent_id TEXT PRIMARY KEY,
11765
- cursor INTEGER NOT NULL
11766
- ) STRICT;
11767
- CREATE TABLE IF NOT EXISTS agent_sequences (
11768
- agent_id TEXT PRIMARY KEY,
11769
- next_seq INTEGER NOT NULL
11770
- ) STRICT;
11771
- `);
11772
- }
11773
- }
11774
- ]);
11775
11677
  var HUB_STORE_SCHEMA_SPEC = Object.freeze({
11776
11678
  schema: HUB_STORE_SCHEMA_V3,
11777
11679
  version: HUB_STORE_SCHEMA_VERSION,
11778
- tables: HUB_STORE_TABLES,
11779
- migrations: HUB_STORE_MIGRATIONS
11680
+ tables: HUB_STORE_TABLES
11780
11681
  });
11781
11682
  var MessageMap = class extends Map {
11782
11683
  store;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.51",
3
+ "version": "0.7.53",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
3
3
  import { join, resolve } from "node:path";
4
4
  import { redactSecrets } from "../redact.ts";
5
5
  import { defaultProjectName } from "../project-name.ts";
6
- import { resolveClientHubAuthToken } from "../hub-env.ts";
6
+ import { hasClientHubCredential, resolveClientHubAuthToken } from "../hub-env.ts";
7
7
  import { agentWorker, type Worker } from "../envelope.ts";
8
8
  import { MESH_TUI_PANELS, runMeshTui, type MeshTuiPanel } from "../tui.ts";
9
9
  import { formatSessionBriefText, loadSessionBriefAsync, type SessionHubStatus } from "../session-work.ts";
@@ -12,6 +12,7 @@ import {
12
12
  HubBindingError,
13
13
  hubBindingFile,
14
14
  probeHubHealth,
15
+ hubBindingScope,
15
16
  readHubBinding,
16
17
  removeHubBinding,
17
18
  validateHubUrl,
@@ -104,8 +105,22 @@ export async function refreshKxmUpdateNotice(runtime: Runtime, config?: KxmUpdat
104
105
  export async function cmdStatus(runtime: Runtime): Promise<number> {
105
106
  const health = await hubGet(`${runtime.serverUrl}/health`, runtime.fetchImpl);
106
107
  const ready = await hubGet(`${runtime.serverUrl}/ready`, runtime.fetchImpl);
107
- const payload = { ok: health.ok && ready.ok, command: "hub view", health: health.body, ready: ready.body };
108
- print(runtime.io, runtime.json, payload, `hub health=${health.ok} ready=${ready.ok}`);
108
+ // Scope on the status line deliberately: "attached across a network" and "attached on
109
+ // this box" are otherwise indistinguishable, and only one of them ships a token.
110
+ // Scope is a property of the URL actually contacted, not of whichever file the
111
+ // binding came from: KXM_SERVER_URL overrides the binding, and labelling the binding
112
+ // while probing an override would report "loopback" about a remote request.
113
+ const effectiveScope = hubBindingScope(runtime.serverUrl);
114
+ const overridden = Boolean(runtime.boundHubUrl && runtime.boundHubUrl !== runtime.serverUrl);
115
+ const payload = {
116
+ ok: health.ok && ready.ok,
117
+ command: "hub view",
118
+ target: { url: runtime.serverUrl, scope: effectiveScope, ...(overridden ? { source: "env" } : {}) },
119
+ health: health.body,
120
+ ready: ready.body,
121
+ };
122
+ print(runtime.io, runtime.json, payload,
123
+ `hub health=${health.ok} ready=${ready.ok} · ${effectiveScope} hub${overridden ? " (KXM_SERVER_URL)" : ""}`);
109
124
  return payload.ok ? 0 : 1;
110
125
  }
111
126
 
@@ -191,6 +206,10 @@ export function formatHubBindHealth(health: HubHealth): string {
191
206
  return "health=unknown (no reply within 300 ms)";
192
207
  }
193
208
 
209
+ /** Kept in one place so the JSON payload and the prose line cannot drift apart. */
210
+ const HUB_BIND_UNAUTHENTICATED_HINT =
211
+ "export KXM_AUTH_TOKEN (or point KXM_STATE_HOME at the hub-env record that already holds one), then re-run; the hub itself requires a token beyond loopback";
212
+
194
213
  export async function cmdHubBind(runtime: Runtime, rawUrl: string): Promise<number> {
195
214
  let url: string;
196
215
  try {
@@ -207,14 +226,73 @@ export async function cmdHubBind(runtime: Runtime, rawUrl: string): Promise<numb
207
226
  }
208
227
  throw error;
209
228
  }
229
+ const scope = hubBindingScope(url);
230
+ // Scope-scoped on purpose, and only for **this command**. A remote binding puts a
231
+ // bearer on a network path, so it is refused when nothing can authenticate it; a
232
+ // stored-but-unusable URL otherwise reads later like a network fault and gets debugged
233
+ // as one. Loopback is not consulted here because a loopback URL puts nothing on a wire —
234
+ // not because credentials are never resolved locally: other client paths still call
235
+ // `resolveClientHubAuthToken` regardless of scope, so a damaged host record can still
236
+ // fail `kxm peer list` on loopback. Narrowing the claim is the point; the first version
237
+ // of this guard checked the record *before* the scope and refused loopback binds that had
238
+ // always worked, which is a regression against behaviour predating this slice.
239
+ if (scope === "remote") {
240
+ // The project that will actually authenticate: a record holding only another
241
+ // project's token cannot authorise this one.
242
+ const bindProject = defaultProjectName(runtime.dirs.workdir, runtime.env) || "project";
243
+ let credentialReady = false;
244
+ try {
245
+ credentialReady = hasClientHubCredential(runtime.env, bindProject);
246
+ } catch (error) {
247
+ // A malformed record is a readable configuration failure, not an uncaught throw
248
+ // past a user-facing entry point.
249
+ print(
250
+ runtime.io,
251
+ runtime.json,
252
+ {
253
+ ok: false,
254
+ command: "hub bind",
255
+ error: "hub_credential_unreadable",
256
+ url,
257
+ scope,
258
+ nextAction: "repair_hub_env_record",
259
+ hint: `${error instanceof Error ? error.message : String(error)}; no binding was written`,
260
+ },
261
+ `cannot read the hub credential: ${error instanceof Error ? error.message : String(error)}`,
262
+ );
263
+ return 2;
264
+ }
265
+ if (!credentialReady) {
266
+ print(
267
+ runtime.io,
268
+ runtime.json,
269
+ {
270
+ ok: false,
271
+ command: "hub bind",
272
+ error: "hub_bind_unauthenticated",
273
+ url,
274
+ scope,
275
+ project: bindProject,
276
+ // The hint belongs in the payload, not only the prose line: under --json the
277
+ // prose is suppressed, and a refusal that names no next step gets debugged by
278
+ // reading source.
279
+ nextAction: "export_kxm_auth_token",
280
+ hint: `${HUB_BIND_UNAUTHENTICATED_HINT} (needs a token for project ${bindProject})`,
281
+ },
282
+ `refusing to bind remote hub ${url} with no credential for project ${bindProject}; ${HUB_BIND_UNAUTHENTICATED_HINT}`,
283
+ );
284
+ return 2;
285
+ }
286
+ }
210
287
  const file = hubBindingFile(runtime.env);
211
288
  if (runtime.dryRun) {
212
- print(runtime.io, runtime.json, { ok: true, command: "hub bind", dryRun: true, url, file }, `would bind hub ${url}`);
289
+ print(runtime.io, runtime.json, { ok: true, command: "hub bind", dryRun: true, url, scope, file }, `would bind hub ${url} (${scope})`);
213
290
  return 0;
214
291
  }
215
292
  writeHubBinding({ schema: HUB_BINDING_SCHEMA, url, boundAt: new Date().toISOString() }, runtime.env);
216
293
  const { health, probeMs } = await probeHubHealth(url, runtime.fetchImpl);
217
- print(runtime.io, runtime.json, { ok: true, command: "hub bind", url, file, health, probeMs }, `bound hub ${url} · ${formatHubBindHealth(health)}`);
294
+ print(runtime.io, runtime.json, { ok: true, command: "hub bind", url, scope, file, health, probeMs },
295
+ `bound hub ${url} · ${scope} · ${formatHubBindHealth(health)}${scope === "remote" ? " · token leaves this machine" : ""}`);
218
296
  return 0;
219
297
  }
220
298
 
@@ -540,6 +618,7 @@ export async function cmdSessionBrief(runtime: Runtime, options: { status?: bool
540
618
  evidence: health === "unknown" ? "timeout" : "probed",
541
619
  online: health === "on",
542
620
  url: targetUrl,
621
+ scope: hubBindingScope(targetUrl),
543
622
  };
544
623
  } else {
545
624
  hub = { state: "off", evidence: "unconfigured", online: false };
@@ -14,17 +14,10 @@ import { basename, dirname, join, resolve } from "node:path";
14
14
  import { DatabaseSync } from "./sqlite.ts";
15
15
  import { KxmConfigError, type KxmConfigIssue } from "./project-config.ts";
16
16
 
17
- export interface DatabaseMigrationStep {
18
- fromVersion: number;
19
- toVersion: number;
20
- migrate: (database: DatabaseSync) => void;
21
- }
22
-
23
17
  export interface DatabaseSchemaSpec {
24
18
  schema: string;
25
19
  version: number;
26
20
  tables?: Readonly<Record<string, readonly string[]>>;
27
- migrations?: readonly DatabaseMigrationStep[];
28
21
  timeoutMs?: number;
29
22
  }
30
23
 
@@ -179,20 +172,15 @@ export function openDatabase(file: string, description: string, spec: DatabaseSc
179
172
  database.exec(spec.schema);
180
173
  database.exec(`PRAGMA user_version = ${spec.version}`);
181
174
  } else if (version < spec.version) {
182
- let currentVersion = version;
183
- while (currentVersion < spec.version) {
184
- const step = spec.migrations?.find((m) => m.fromVersion === currentVersion);
185
- if (!step) {
186
- throw databaseError(
187
- "runtime_schema_outdated",
188
- file,
189
- `${description} schema version ${version} is older than ${spec.version}; no migration lane, backup and restore remain E6`,
190
- );
191
- }
192
- step.migrate(database);
193
- currentVersion = step.toVersion;
194
- database.exec(`PRAGMA user_version = ${currentVersion}`);
195
- }
175
+ // No migration lanes. This is a single-operator tool: an older database is
176
+ // re-initialised, not upgraded in place, and the code never carries two schema
177
+ // shapes at once. Silently accepting an older file would mean every query has to
178
+ // work against shapes it no longer tests.
179
+ throw databaseError(
180
+ "runtime_schema_outdated",
181
+ file,
182
+ `${description} is schema version ${version}; this build requires ${spec.version}. Delete the state file (or re-run \`kxm init\`) to start fresh — upgrading old state in place is deliberately unsupported`,
183
+ );
196
184
  }
197
185
 
198
186
  if (spec.tables) {
@@ -16,6 +16,7 @@ import {
16
16
  lstatSync,
17
17
  } from "node:fs";
18
18
  import { dirname, join, resolve } from "node:path";
19
+ import { openDatabase } from "./database.ts";
19
20
  import { spawnSync } from "node:child_process";
20
21
 
21
22
  export const EXTERNAL_EFFECT_SCHEMA = "kxm.external-effect-receipt.v1" as const;
@@ -114,6 +115,33 @@ export function computeEffectKey(
114
115
  return `eff_${createHash("sha256").update(raw).digest("hex").slice(0, 16)}`;
115
116
  }
116
117
 
118
+ const EXTERNAL_EFFECTS_SCHEMA_VERSION = 1;
119
+
120
+ const EXTERNAL_EFFECTS_DDL = `
121
+ CREATE TABLE IF NOT EXISTS external_effects (
122
+ effect_key TEXT PRIMARY KEY,
123
+ run_id TEXT NOT NULL,
124
+ step_id TEXT NOT NULL,
125
+ attempt_id TEXT NOT NULL,
126
+ action_kind TEXT NOT NULL,
127
+ target_ref TEXT NOT NULL,
128
+ status TEXT NOT NULL,
129
+ payload_hash TEXT NOT NULL,
130
+ receipt_payload TEXT NOT NULL,
131
+ executed_at TEXT NOT NULL,
132
+ last_heartbeat_at TEXT,
133
+ completed_at TEXT
134
+ );
135
+ CREATE INDEX IF NOT EXISTS idx_ext_effects_run ON external_effects(run_id);
136
+ `;
137
+
138
+ const EXTERNAL_EFFECTS_SHAPE = {
139
+ external_effects: [
140
+ "effect_key", "run_id", "step_id", "attempt_id", "action_kind", "target_ref",
141
+ "status", "payload_hash", "receipt_payload", "executed_at", "last_heartbeat_at", "completed_at",
142
+ ],
143
+ } as const;
144
+
117
145
  export class ExternalEffectsLedger {
118
146
  private db: DatabaseSync;
119
147
 
@@ -121,33 +149,15 @@ export class ExternalEffectsLedger {
121
149
  if (dbPath !== ":memory:") {
122
150
  mkdirSync(dirname(dbPath), { recursive: true });
123
151
  }
124
- this.db = new DatabaseSync(dbPath);
125
- this.initSchema();
126
- }
127
-
128
- private initSchema(): void {
129
- this.db.exec(`
130
- CREATE TABLE IF NOT EXISTS external_effects (
131
- effect_key TEXT PRIMARY KEY,
132
- run_id TEXT NOT NULL,
133
- step_id TEXT NOT NULL,
134
- attempt_id TEXT NOT NULL,
135
- action_kind TEXT NOT NULL,
136
- target_ref TEXT NOT NULL,
137
- status TEXT NOT NULL,
138
- payload_hash TEXT NOT NULL,
139
- receipt_payload TEXT NOT NULL,
140
- executed_at TEXT NOT NULL,
141
- last_heartbeat_at TEXT,
142
- completed_at TEXT
143
- );
144
- CREATE INDEX IF NOT EXISTS idx_ext_effects_run ON external_effects(run_id);
145
- `);
146
- try {
147
- this.db.exec(`ALTER TABLE external_effects ADD COLUMN last_heartbeat_at TEXT;`);
148
- } catch {
149
- // Column already present in schema
150
- }
152
+ // Goes through the shared opener, not a bare `new DatabaseSync`: that is what gives this
153
+ // store the same contract as every other one — path checks, WAL, the `user_version` gate
154
+ // that refuses an older stamp outright, and the column-shape check that turns a missing
155
+ // column into `runtime_schema_shape_invalid` here instead of a bare SQL error on first use.
156
+ this.db = openDatabase(dbPath, "external effects ledger", {
157
+ schema: EXTERNAL_EFFECTS_DDL,
158
+ version: EXTERNAL_EFFECTS_SCHEMA_VERSION,
159
+ tables: EXTERNAL_EFFECTS_SHAPE,
160
+ });
151
161
  }
152
162
 
153
163
  /**
@@ -7,6 +7,15 @@ export const HUB_HEALTH_PROBE_MS = 300;
7
7
 
8
8
  export type HubHealth = "on" | "off" | "unknown";
9
9
 
10
+ /**
11
+ * Where a bound hub sits relative to this machine — a trust question, not a cosmetic one.
12
+ * A loopback URL never puts a bearer on a network; a remote URL means the operator chose
13
+ * to. `kxm hub bind` therefore refuses a remote URL it cannot authenticate, mirroring the
14
+ * rule the hub applies to its own listener ("KXM_AUTH_TOKEN is required when binding
15
+ * beyond localhost").
16
+ */
17
+ export type HubBindingScope = "loopback" | "remote";
18
+
10
19
  export interface HubBindingRecord {
11
20
  schema: typeof HUB_BINDING_SCHEMA;
12
21
  url: string;
@@ -62,6 +71,19 @@ export function validateHubUrl(raw: string): string {
62
71
  return parsed.href.replace(/\/$/, "");
63
72
  }
64
73
 
74
+ /** Loopback literals only; `0.0.0.0`, a LAN address or a hostname are all remote. */
75
+ export function hubBindingScope(url: string): HubBindingScope {
76
+ let host: string;
77
+ try {
78
+ host = new URL(url).hostname.toLowerCase();
79
+ } catch {
80
+ return "remote";
81
+ }
82
+ if (host === "localhost" || host === "::1" || host === "[::1]" || host.endsWith(".localhost")) return "loopback";
83
+ const v4 = /^127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/.exec(host);
84
+ return v4 && [v4[1], v4[2], v4[3]].every((part) => Number(part) <= 255) ? "loopback" : "remote";
85
+ }
86
+
65
87
  function isIsoTimestamp(value: string): boolean {
66
88
  if (Number.isNaN(Date.parse(value))) return false;
67
89
  return value === new Date(value).toISOString();