@baize-ai/core 0.3.13 → 0.3.15

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/.dockerignore CHANGED
@@ -6,5 +6,3 @@
6
6
  !templates/pm2/ecosystem.config.cjs
7
7
  !.claude-local/claude
8
8
  !.claude-local/claude.version
9
- !skills/
10
- !.docker-local-skills/
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to baize-core will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.15] - 2026-08-31
9
+
10
+ ### Fixed
11
+ - A2A daemon 监控自愈统一收编 `baize init`(D49):init 幂等 bootstrap(enabled → detached `cli.js start --background` → daemon-ctl pm2+waitReady → pm2 save),结果落 `components/a2a/bootstrap-state.json` 并在 web 状态卡展示;Docker 与裸机同一路径,entrypoint 的 Docker 专属 Step 3b(D48)删除
12
+ - core pm2 ecosystem 不再纳管 baize-a2a(D49):其生命周期唯一归属 daemon-ctl——消除三条启动路径并存导致的 8443 双实例竞态
13
+
14
+ ### Added
15
+ - `baize upgrade <name> --file <tgz>`(D49):商业组件离线升级——manifest/版本闸(新 > 旧)、备份回滚、既有 9 步管线;step8 重启钩子对 baize-a2a 优先走其 `cli.js restart`(覆盖 nohup daemon,pm2-only 路径的盲区修复)
16
+ - getLocalVersion 优先读组件 package.json(SKILL.md frontmatter 曾恒 0.1.0 → 升级判定失真)
17
+ - Web 控制台 A2A 上传:已安装 = 升级(版本闸 409 version_not_higher + 备份回滚 + 自动重启 daemon),安装/升级双语义
18
+ - docker-compose healthcheck:A2A enabled 时追加 TCP 8443 就绪探测(pm2 list 无法发现静默空转的 daemon)
19
+
20
+ ## [0.3.14] - 2026-08-30
21
+
22
+ ### Fixed
23
+ - A2A 容器重启自愈(D48):entrypoint 检查 A2A config.enabled → 自动启动 baize-a2a daemon——容器重启后 A2A 零手动恢复
24
+ - Web A2A 状态卡证书徽章(D48):显示证书链状态(certValid)——daemon 在线时"自动处理中"、离线时提示"待续签"
25
+
8
26
  ## [0.3.13] - 2026-08-28
9
27
 
10
28
  ### Fixed
package/Dockerfile CHANGED
@@ -82,11 +82,6 @@ ENV WEB_CONSOLE_BIND=0.0.0.0
82
82
  # build context) — source stays private, the image matches the npm version
83
83
  # exactly. Build with: docker build --build-arg BAZE_CORE_VERSION=0.2.0 .
84
84
  ARG BAZE_CORE_VERSION=latest
85
- # Local-development override: when building from source with
86
- # --build-arg BAIZE_LOCAL_SKILLS=1, copy the LOCAL skills over the npm-package
87
- # skills so un-released fixes (e.g. D44) land in the image without publishing.
88
- # Official builds leave this off — the published npm package is authoritative.
89
- ARG BAIZE_LOCAL_SKILLS=0
90
85
  WORKDIR /home/baize
91
86
  RUN npm install -g @baize-ai/core@${BAZE_CORE_VERSION} \
92
87
  && baize --version \
@@ -129,14 +124,6 @@ RUN npm install -g @baize-ai/core@${BAZE_CORE_VERSION} \
129
124
  && node -e "const D=require('/home/baize/baize/.claude/skills/web-console/node_modules/better-sqlite3'); new D(':memory:')" \
130
125
  && echo "native modules OK (locally compiled, baked into runtime skills)"
131
126
 
132
- # ── Local-source skills override (BAIZE_LOCAL_SKILLS=1) ─────────────────────
133
- # docker-publish.sh stages the repo's skills/ into .docker-local-skills/ when
134
- # BAIZE_LOCAL_SKILLS=1 (empty directory otherwise). They land in
135
- # /home/baize/.local-skills and are applied by entrypoint AFTER init, because
136
- # init's postinstall syncSkills uses the npm-package skills as source and
137
- # would otherwise overwrite them. Official images carry an empty dir — no-op.
138
- COPY --chown=baize:baize .docker-local-skills/ /home/baize/.local-skills/
139
-
140
127
  # ── Workspace directories ─────────────────────────────────────────────────────
141
128
  # ~/baize is mounted as a single volume in docker-compose.yml.
142
129
  # Creating subdirectories here ensures correct ownership in the image.
@@ -714,6 +714,9 @@ async function installDeclarative(resolved, skillDir, skipConfirm, jsonOutput, b
714
714
  console.log(` ${success(`${bold(service.name || ('baize-' + resolved.name))} started`)}`);
715
715
  } else {
716
716
  console.log(` ${error(`Failed to start service: ${svcResult.error}`)}`);
717
+ if (name === 'a2a') {
718
+ console.log(` ${dim('Run `baize a2a start` (or \`baize init\`) to bring the A2A daemon up.')}`);
719
+ }
717
720
  console.log(` ${dim('You can start it manually later.')}`);
718
721
  }
719
722
  }
@@ -3,14 +3,15 @@
3
3
  */
4
4
 
5
5
  import fs from 'node:fs';
6
+ import os from 'node:os';
6
7
  import path from 'node:path';
7
8
  import { execFileSync } from 'node:child_process';
8
9
  import { BAIZE_DIR, SKILLS_DIR, COMPONENTS_DIR, getBaizeConfig } from '../lib/config.js';
9
10
  import { bold, dim, green, red, yellow, cyan, success, error, warn, heading } from '../lib/colors.js';
10
11
  import { loadRegistry } from '../lib/registry.js';
11
- import { searchNpmPackages, npmRegistryUrl } from '../lib/download.js';
12
+ import { searchNpmPackages, npmRegistryUrl, acquireSource } from '../lib/download.js';
12
13
  import { loadComponents, saveComponents } from '../lib/components.js';
13
- import { checkForUpdates, getLocalSourceUpgradeError, getRepo, runUpgrade, downloadToTemp, readChangelog, filterChangelog, cleanupTemp } from '../lib/upgrade.js';
14
+ import { checkForUpdates, getLocalVersion, getLocalSourceUpgradeError, getRepo, runUpgrade, downloadToTemp, readChangelog, filterChangelog, cleanupTemp } from '../lib/upgrade.js';
14
15
  import {
15
16
  checkForCoreUpdates, runSelfUpgrade,
16
17
  downloadCoreToTemp, readChangelog as readCoreChangelog,
@@ -21,7 +22,7 @@ import { parseSkillMd } from '../lib/skill.js';
21
22
  import { linkBins, unlinkBins } from '../lib/bin.js';
22
23
  import { removeCaddyRoutes } from '../lib/caddy.js';
23
24
  import { acquireLock, releaseLock } from '../lib/lock.js';
24
- import { fetchRawFile } from '../lib/github.js';
25
+ import { fetchRawFile, compareSemverDesc } from '../lib/github.js';
25
26
  import { promptYesNo } from '../lib/prompts.js';
26
27
  import { evaluateUpgrade } from '../lib/claude-eval.js';
27
28
 
@@ -251,6 +252,8 @@ export async function upgradeComponent(args) {
251
252
  const skipEval = args.includes('--skip-eval');
252
253
  const beta = args.includes('--beta');
253
254
  const hasTempDirFlag = args.includes('--temp-dir');
255
+ const fileIndex = args.indexOf('--file');
256
+ const upgradeFile = fileIndex !== -1 ? args[fileIndex + 1] : null;
254
257
 
255
258
  if (hasTempDirFlag) {
256
259
  const msg = '--temp-dir is not supported.';
@@ -272,6 +275,17 @@ export async function upgradeComponent(args) {
272
275
  process.exit(1);
273
276
  }
274
277
 
278
+ if (fileIndex !== -1 && (!upgradeFile || upgradeFile.startsWith('-'))) {
279
+ console.error('Error: --file requires a path to a .tgz archive.');
280
+ process.exit(1);
281
+ }
282
+
283
+ // --file is a fully offline source: remote/branch resolution flags make no sense.
284
+ if (upgradeFile && (branch || beta)) {
285
+ console.error('Error: --file cannot be combined with --branch or --beta.');
286
+ process.exit(1);
287
+ }
288
+
275
289
  // --beta and --branch are mutually exclusive
276
290
  if (beta && branch) {
277
291
  console.error('Error: --beta and --branch are mutually exclusive.');
@@ -291,7 +305,7 @@ export async function upgradeComponent(args) {
291
305
  }
292
306
 
293
307
  // Get target component (filter out flags and flag values)
294
- const flagsWithValues = new Set(['--branch', '--mode']);
308
+ const flagsWithValues = new Set(['--branch', '--mode', '--file']);
295
309
  const target = args.find((a, i) => {
296
310
  if (a.startsWith('-')) return false;
297
311
  if (a === 'confirm') return false;
@@ -300,6 +314,12 @@ export async function upgradeComponent(args) {
300
314
  return true;
301
315
  });
302
316
 
317
+ // --file upgrades one installed component from a local tarball.
318
+ if (upgradeFile && (upgradeAll || upgradeSelf)) {
319
+ console.error('Error: --file upgrades a single installed component; it cannot be combined with --all or --self.');
320
+ process.exit(1);
321
+ }
322
+
303
323
  // Handle --self: upgrade baize-core itself
304
324
  if (upgradeSelf) {
305
325
  if (checkOnly) {
@@ -326,6 +346,7 @@ export async function upgradeComponent(args) {
326
346
  console.log(' --yes, -y Skip confirmation');
327
347
  console.log(' --skip-eval Skip upgrade analysis of local changes');
328
348
  console.log(' --beta Include prerelease (beta) versions');
349
+ console.log(' --file <tgz> Upgrade from a local .tar.gz archive (offline delivery)');
329
350
  console.log(' --branch <b> Upgrade from a specific branch (e.g. feat/xxx)');
330
351
  console.log(' --mode <m> Merge mode: "merge" (default, smart three-way) or "overwrite"');
331
352
  console.log('\nExamples:');
@@ -356,20 +377,25 @@ export async function upgradeComponent(args) {
356
377
  process.exit(1);
357
378
  }
358
379
 
359
- const localSourceError = getLocalSourceUpgradeError(target, components[target]);
360
- if (localSourceError) {
361
- const result = {
362
- action: checkOnly ? 'check' : 'upgrade',
363
- component: target,
364
- ...localSourceError,
365
- reply: localSourceError.message,
366
- };
367
- if (jsonOutput) {
368
- console.log(JSON.stringify(result, null, 2));
369
- } else {
370
- console.error(`Error: ${localSourceError.message}`);
380
+ if (upgradeFile) {
381
+ // The tarball IS the local source for this upgrade — a local-source
382
+ // registration does not block it.
383
+ } else {
384
+ const localSourceError = getLocalSourceUpgradeError(target, components[target]);
385
+ if (localSourceError) {
386
+ const result = {
387
+ action: checkOnly ? 'check' : 'upgrade',
388
+ component: target,
389
+ ...localSourceError,
390
+ reply: localSourceError.message,
391
+ };
392
+ if (jsonOutput) {
393
+ console.log(JSON.stringify(result, null, 2));
394
+ } else {
395
+ console.error(`Error: ${localSourceError.message}`);
396
+ }
397
+ process.exit(1);
371
398
  }
372
- process.exit(1);
373
399
  }
374
400
 
375
401
  if (!fs.existsSync(skillDir)) {
@@ -388,6 +414,17 @@ export async function upgradeComponent(args) {
388
414
  process.exit(1);
389
415
  }
390
416
 
417
+ // Mode 0: offline upgrade from a local tgz (K4 commercial delivery —
418
+ // baize-a2a never ships to npm; --file and web upload are the only channels).
419
+ if (upgradeFile) {
420
+ if (checkOnly) {
421
+ return handleFileCheckOnly(target, upgradeFile, { jsonOutput });
422
+ }
423
+ const ok = await handleFileUpgradeFlow(target, upgradeFile, { jsonOutput, skipConfirm: skipConfirm || explicitConfirm, mode });
424
+ if (!ok) process.exit(1);
425
+ return;
426
+ }
427
+
391
428
  // Mode 1: Check only (--check) — no lock, downloads to temp for file comparison
392
429
  if (checkOnly) {
393
430
  return handleCheckOnly(target, { jsonOutput, branch, beta });
@@ -717,29 +754,7 @@ async function handleUpgradeFlow(component, { jsonOutput, skipConfirm, skipEval,
717
754
  });
718
755
 
719
756
  if (result.success) {
720
- // Phase C: Cleanup
721
- // Update components.json
722
- const components = loadComponents();
723
- if (components[component]) {
724
- components[component].version = result.to || components[component].version;
725
- components[component].upgradedAt = new Date().toISOString();
726
-
727
- // Update bin symlinks (remove old, create new)
728
- const oldBin = components[component].bin;
729
- if (oldBin) unlinkBins(oldBin);
730
- const updatedSkill = parseSkillMd(skillDir);
731
- const newBin = linkBins(skillDir, updatedSkill?.frontmatter?.bin);
732
- if (newBin) {
733
- components[component].bin = newBin;
734
- } else {
735
- delete components[component].bin;
736
- }
737
-
738
- saveComponents(components);
739
- }
740
-
741
- // Clean old backups (keep only the latest)
742
- cleanOldBackups(skillDir);
757
+ applyUpgradeBookkeeping(component, skillDir, result);
743
758
  }
744
759
 
745
760
  // Output result
@@ -777,6 +792,240 @@ async function handleUpgradeFlow(component, { jsonOutput, skipConfirm, skipEval,
777
792
  }
778
793
  }
779
794
 
795
+ /**
796
+ * Post-success bookkeeping shared by every upgrade path: bump components.json,
797
+ * refresh bin symlinks, prune old backups.
798
+ */
799
+ function applyUpgradeBookkeeping(component, skillDir, result) {
800
+ const components = loadComponents();
801
+ if (components[component]) {
802
+ components[component].version = result.to || components[component].version;
803
+ components[component].upgradedAt = new Date().toISOString();
804
+
805
+ // Update bin symlinks (remove old, create new)
806
+ const oldBin = components[component].bin;
807
+ if (oldBin) unlinkBins(oldBin);
808
+ const updatedSkill = parseSkillMd(skillDir);
809
+ const newBin = linkBins(skillDir, updatedSkill?.frontmatter?.bin);
810
+ if (newBin) {
811
+ components[component].bin = newBin;
812
+ } else {
813
+ delete components[component].bin;
814
+ }
815
+
816
+ saveComponents(components);
817
+ }
818
+
819
+ // Clean old backups (keep only the latest)
820
+ cleanOldBackups(skillDir);
821
+ }
822
+
823
+ /**
824
+ * Resolve a --file path (~ expansion + absolutize).
825
+ */
826
+ function resolveTgzPath(filePath) {
827
+ const expanded = filePath === '~'
828
+ ? os.homedir()
829
+ : filePath.startsWith('~/')
830
+ ? path.join(os.homedir(), filePath.slice(2))
831
+ : filePath;
832
+ return path.resolve(expanded);
833
+ }
834
+
835
+ /**
836
+ * Extract a local .tgz into a fresh temp dir and read its package manifest.
837
+ * Safety vetting (path traversal / links) is delegated to the local-tarball
838
+ * source resolver in cli/lib/download.js — the same gate `baize add` uses.
839
+ *
840
+ * @returns {{ ok: true, tempDir: string, version: string, name: string|null }
841
+ * | { ok: false, error: string }}
842
+ */
843
+ function extractTarballArtifact(tgzPath) {
844
+ if (!/\.(?:tar\.gz|tgz)$/i.test(tgzPath)) {
845
+ return { ok: false, error: `--file must be a .tgz archive: ${tgzPath}` };
846
+ }
847
+ if (!fs.existsSync(tgzPath)) {
848
+ return { ok: false, error: `File not found: ${tgzPath}` };
849
+ }
850
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'baize-upgrade-file-'));
851
+ const extract = acquireSource({ type: 'local-tarball', path: tgzPath }, tempDir);
852
+ if (!extract.success) {
853
+ cleanupTemp(tempDir);
854
+ return { ok: false, error: extract.error || 'Failed to extract tarball' };
855
+ }
856
+ let version = null;
857
+ let name = null;
858
+ try {
859
+ const pkg = JSON.parse(fs.readFileSync(path.join(tempDir, 'package.json'), 'utf8'));
860
+ if (typeof pkg.version === 'string' && pkg.version.trim()) version = pkg.version.trim();
861
+ if (typeof pkg.name === 'string' && pkg.name.trim()) name = pkg.name.trim();
862
+ } catch {
863
+ // missing/invalid package.json → version stays null, rejected by caller
864
+ }
865
+ if (!version) {
866
+ cleanupTemp(tempDir);
867
+ return { ok: false, error: 'Tarball has no package.json version' };
868
+ }
869
+ return { ok: true, tempDir, version, name };
870
+ }
871
+
872
+ /**
873
+ * K4: offline upgrade from a local .tgz (commercial delivery channel —
874
+ * baize-a2a never ships to npm, so --file and web upload are the only
875
+ * upgrade paths). Skips checkForUpdates/downloadToTemp: the tarball IS the
876
+ * source. Version gate: incoming semver must be strictly higher than the
877
+ * locally installed one (getLocalVersion reads package.json first — K6).
878
+ * Everything downstream reuses the existing 9-step runUpgrade pipeline
879
+ * (backup → smart merge → npm install → baseline → restart), including
880
+ * failure rollback.
881
+ *
882
+ * Returns true on success, false on failure. No process.exit() here.
883
+ */
884
+ async function handleFileUpgradeFlow(component, filePath, { jsonOutput, skipConfirm, mode = 'merge' }) {
885
+ const skillDir = path.join(SKILLS_DIR, component);
886
+ let tempDir = null;
887
+
888
+ const failWith = (payload) => {
889
+ if (jsonOutput) {
890
+ const errOutput = { action: 'upgrade', component, success: false, ...payload };
891
+ errOutput.reply = formatC4Reply('error', { message: payload.message || payload.error });
892
+ console.log(JSON.stringify(errOutput, null, 2));
893
+ } else {
894
+ console.error(`Error: ${payload.message || payload.error}`);
895
+ }
896
+ return false;
897
+ };
898
+
899
+ const artifact = extractTarballArtifact(resolveTgzPath(filePath));
900
+ if (!artifact.ok) {
901
+ return failWith({ error: artifact.error });
902
+ }
903
+ tempDir = artifact.tempDir;
904
+
905
+ const lockResult = acquireLock(component);
906
+ if (!lockResult.success) {
907
+ return failWith({ error: lockResult.error });
908
+ }
909
+
910
+ try {
911
+ // Manifest sanity: the artifact must be the same package as installed.
912
+ let installedName = null;
913
+ try {
914
+ const localPkg = JSON.parse(fs.readFileSync(path.join(skillDir, 'package.json'), 'utf8'));
915
+ if (typeof localPkg.name === 'string') installedName = localPkg.name;
916
+ } catch {
917
+ // skill package.json is optional for the name check
918
+ }
919
+ if (installedName && artifact.name && artifact.name !== installedName) {
920
+ return failWith({ error: `manifest name mismatch: installed ${installedName}, tarball ${artifact.name}` });
921
+ }
922
+
923
+ const local = getLocalVersion(skillDir);
924
+ if (!local.success) {
925
+ return failWith({ error: `Cannot read current version: ${local.error}` });
926
+ }
927
+
928
+ if (compareSemverDesc(local.version, artifact.version) <= 0) {
929
+ return failWith({
930
+ error: 'version_not_higher',
931
+ message: `Incoming version ${artifact.version} is not higher than installed ${local.version} — nothing to upgrade.`,
932
+ local: local.version,
933
+ incoming: artifact.version,
934
+ });
935
+ }
936
+
937
+ if (!jsonOutput) {
938
+ console.log(`\n${bold(component)}: ${dim(local.version)} → ${bold(artifact.version)}`);
939
+ console.log(dim(`Source: local tarball (${filePath})`));
940
+ }
941
+
942
+ // Confirmation (no download/eval steps — the artifact is already local)
943
+ if (!skipConfirm) {
944
+ const confirmed = await promptYesNo('Proceed with upgrade? [y/N]: ');
945
+ if (!confirmed) {
946
+ console.log('Upgrade cancelled.');
947
+ return true; // Not an error — user chose to cancel
948
+ }
949
+ } else if (!jsonOutput) {
950
+ console.log(`Upgrading ${bold(component)}...`);
951
+ }
952
+
953
+ const result = runUpgrade(component, {
954
+ tempDir,
955
+ newVersion: artifact.version,
956
+ mode,
957
+ jsonOutput,
958
+ onStep: !jsonOutput ? printStep : undefined,
959
+ });
960
+
961
+ if (result.success) {
962
+ applyUpgradeBookkeeping(component, skillDir, result);
963
+ }
964
+
965
+ if (jsonOutput) {
966
+ const output = { ...result, source: { type: 'local-tarball', path: resolveTgzPath(filePath) } };
967
+ output.reply = formatC4Reply('upgrade', { component, ...result });
968
+ console.log(JSON.stringify(output, null, 2));
969
+ } else if (result.success) {
970
+ console.log(`\n${success(`${bold(component)} upgraded: ${dim(result.from)} → ${bold(result.to)}`)}`);
971
+ } else {
972
+ console.log(`\n${error(`Upgrade failed (step ${result.failedStep}): ${result.error}`)}`);
973
+ if (result.rollback?.performed) {
974
+ console.log(`\n${bold('Auto-rollback performed:')}`);
975
+ for (const r of result.rollback.steps) {
976
+ if (r.success) console.log(` ${success(r.action)}`);
977
+ else console.log(` ${error(r.action)}`);
978
+ }
979
+ }
980
+ }
981
+
982
+ return result.success;
983
+ } finally {
984
+ cleanupTemp(tempDir);
985
+ releaseLock(component);
986
+ }
987
+ }
988
+
989
+ /**
990
+ * K4 --check variant: compare a local tgz against the installed version
991
+ * without touching the installation (no lock needed).
992
+ */
993
+ function handleFileCheckOnly(component, filePath, { jsonOutput }) {
994
+ const skillDir = path.join(SKILLS_DIR, component);
995
+ const artifact = extractTarballArtifact(resolveTgzPath(filePath));
996
+ if (!artifact.ok) {
997
+ if (jsonOutput) {
998
+ console.log(JSON.stringify({ action: 'check', component, success: false, error: artifact.error }, null, 2));
999
+ } else {
1000
+ console.error(`Error: ${artifact.error}`);
1001
+ }
1002
+ process.exit(1);
1003
+ }
1004
+
1005
+ const local = getLocalVersion(skillDir);
1006
+ const hasUpdate = local.success && compareSemverDesc(local.version, artifact.version) > 0;
1007
+ const result = {
1008
+ action: 'check',
1009
+ component,
1010
+ success: local.success,
1011
+ hasUpdate,
1012
+ current: local.success ? local.version : null,
1013
+ latest: artifact.version,
1014
+ source: { type: 'local-tarball', path: resolveTgzPath(filePath) },
1015
+ ...(local.success ? {} : { error: local.error }),
1016
+ };
1017
+ if (jsonOutput) {
1018
+ console.log(JSON.stringify(result, null, 2));
1019
+ } else if (hasUpdate) {
1020
+ console.log(`${bold(component)}: ${dim(result.current)} → ${bold(result.latest)} (local tarball)`);
1021
+ } else if (local.success) {
1022
+ console.log(success(`${bold(component)} is up to date (v${result.current}); tarball v${artifact.version} is not higher.`));
1023
+ } else {
1024
+ console.error(`Error: ${local.error}`);
1025
+ process.exit(1);
1026
+ }
1027
+ }
1028
+
780
1029
  /**
781
1030
  * Clean old .backup/ directories, keeping only the latest.
782
1031
  */
@@ -1145,6 +1145,47 @@ function startCoreServices(webPassword = null) {
1145
1145
  }
1146
1146
  }
1147
1147
 
1148
+ // ── A2A daemon bootstrap (D49) ───────────────────────────────────
1149
+
1150
+ /**
1151
+ * Fire-and-forget A2A daemon bootstrap. When the commercial baize-a2a
1152
+ * component is installed AND enabled, bring its daemon up through the
1153
+ * component's own CLI (`start --background`), which:
1154
+ * - prefers pm2 (autorestart + `pm2 save`/resurrect survive reboots),
1155
+ * - waits for TCP + card-identity readiness (30s),
1156
+ * - falls back to a nohup daemon when pm2 is unavailable,
1157
+ * - records the outcome in components/a2a/bootstrap-state.json.
1158
+ *
1159
+ * CRITICAL: this must NEVER block init. The child runs detached; init does
1160
+ * not wait and cannot fail because of it. Docker and bare metal take the
1161
+ * exact same path — the Docker-only entrypoint self-heal (D48 Step 3b) was
1162
+ * removed in favor of this unified bootstrap (user decision, 2026-08-31).
1163
+ */
1164
+ function bootstrapA2aDaemon({ quiet = false } = {}) {
1165
+ try {
1166
+ const cli = path.join(SKILLS_DIR, 'a2a', 'scripts', 'cli.js');
1167
+ const a2aConfigPath = path.join(COMPONENTS_DIR, 'a2a', 'config.json');
1168
+ if (!fs.existsSync(cli) || !fs.existsSync(a2aConfigPath)) return;
1169
+ let enabled = false;
1170
+ try {
1171
+ enabled = JSON.parse(fs.readFileSync(a2aConfigPath, 'utf8')).enabled === true;
1172
+ } catch {
1173
+ return;
1174
+ }
1175
+ if (!enabled) return;
1176
+ fs.mkdirSync(path.join(COMPONENTS_DIR, 'a2a', 'logs'), { recursive: true });
1177
+ const child = spawn(process.execPath, [cli, 'start', '--background'], {
1178
+ detached: true,
1179
+ stdio: 'ignore',
1180
+ env: process.env,
1181
+ });
1182
+ child.unref();
1183
+ if (!quiet) console.log(` ${dim('A2A daemon bootstrap launched (background) — see components/a2a/bootstrap-state.json.')}`);
1184
+ } catch (err) {
1185
+ if (!quiet) console.log(` ${warn(`A2A bootstrap failed to launch: ${err.message}`)}`);
1186
+ }
1187
+ }
1188
+
1148
1189
  // ── PM2 boot auto-start ──────────────────────────────────────────
1149
1190
 
1150
1191
  /**
@@ -2394,6 +2435,7 @@ export async function initCommand(args) {
2394
2435
  }
2395
2436
  if (!quiet) console.log(heading('Starting services...'));
2396
2437
  const servicesStarted = startCoreServices(opts.webPassword);
2438
+ bootstrapA2aDaemon({ quiet });
2397
2439
  if (servicesStarted > 0) {
2398
2440
  setupPm2Startup();
2399
2441
  if (!quiet) console.log(`\n${green(`${servicesStarted} service(s) started.`)} ${dim('Run "baize status" to check.')}`);
@@ -2516,6 +2558,7 @@ export async function initCommand(args) {
2516
2558
  // Step 11: Start services
2517
2559
  if (!quiet) console.log(`\n${heading('Starting services...')}`);
2518
2560
  const servicesStarted = startCoreServices(opts.webPassword);
2561
+ bootstrapA2aDaemon({ quiet });
2519
2562
 
2520
2563
  if (servicesStarted > 0) {
2521
2564
  setupPm2Startup();
@@ -21,31 +21,35 @@ import { copyTree, syncTree } from './fs-utils.js';
21
21
  import { applyCaddyRoutes } from './caddy.js';
22
22
  import { smartSync, formatMergeResult } from './smart-merge.js';
23
23
  import { restartFromEcosystem, restartManagedProcess } from './pm2.js';
24
+ import { a2aCliPath } from './a2a.js';
24
25
 
25
26
  // ---------------------------------------------------------------------------
26
27
  // Version helpers
27
28
  // ---------------------------------------------------------------------------
28
29
 
30
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
31
+
29
32
  /**
30
- * Read the local version from SKILL.md frontmatter, falling back to package.json.
33
+ * Read the local version of an installed component (K6).
34
+ * Primary: skillDir/package.json `version` (valid semver only — SKILL.md
35
+ * frontmatter historically stayed at 0.1.0 while package.json advanced, which
36
+ * broke upgrade version gating). Fallback: SKILL.md frontmatter.
31
37
  */
32
- function getLocalVersion(skillDir) {
33
- // Primary: SKILL.md frontmatter
34
- const parsed = parseSkillMd(skillDir);
35
- if (parsed?.frontmatter?.version) {
36
- return { success: true, version: String(parsed.frontmatter.version) };
37
- }
38
- // Fallback: package.json
38
+ export function getLocalVersion(skillDir) {
39
39
  const pkgPath = path.join(skillDir, 'package.json');
40
40
  try {
41
41
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
42
- if (pkg.version) {
43
- return { success: true, version: String(pkg.version) };
42
+ if (typeof pkg.version === 'string' && SEMVER_RE.test(pkg.version.trim())) {
43
+ return { success: true, version: pkg.version.trim() };
44
44
  }
45
45
  } catch {
46
- // package.json doesn't exist or is invalid
46
+ // package.json missing or invalid — fall through to SKILL.md
47
+ }
48
+ const parsed = parseSkillMd(skillDir);
49
+ if (parsed?.frontmatter?.version) {
50
+ return { success: true, version: String(parsed.frontmatter.version) };
47
51
  }
48
- return { success: false, error: 'Version not found in SKILL.md or package.json' };
52
+ return { success: false, error: 'Version not found in package.json or SKILL.md' };
49
53
  }
50
54
 
51
55
  /**
@@ -645,8 +649,27 @@ function truncateHookOutput(value, maxLength = 1000) {
645
649
  return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
646
650
  }
647
651
 
652
+ const A2A_CLI_RESTART_TIMEOUT_MS = 120000;
653
+
654
+ /**
655
+ * Resolve the baize-a2a scripts/cli.js for the K5 restart hook. The component's
656
+ * own skill dir wins (that is where `baize add` / web install put it); the
657
+ * fallback reuses cli/lib/a2a.js resolution (env override included).
658
+ */
659
+ function defaultResolveA2aCli(skillDir) {
660
+ const local = path.join(skillDir, 'scripts', 'cli.js');
661
+ if (fs.existsSync(local)) return local;
662
+ return a2aCliPath();
663
+ }
664
+
648
665
  /**
649
- * Step 8: restart PM2 service (if it was running before upgrade)
666
+ * Step 8: restart the component service (if it was running before upgrade).
667
+ *
668
+ * K5 restart hook: a nohup-started a2a daemon (Docker entrypoint) is invisible
669
+ * to pm2, so the pm2-only restart below never refreshed its code (C6). For
670
+ * service name 'baize-a2a' the a2a CLI `restart` (daemon-ctl: nohup pid +
671
+ * pm2 + waitReady) is preferred — it covers both lifecycles — falling back to
672
+ * the existing pm2 path when the CLI is unavailable or fails.
650
673
  */
651
674
  export function step8_startService(ctx, deps = {}) {
652
675
  const startTime = Date.now();
@@ -654,13 +677,30 @@ export function step8_startService(ctx, deps = {}) {
654
677
  const exists = deps.existsSync ?? fs.existsSync;
655
678
  const restartManaged = deps.restartManagedProcess ?? restartManagedProcess;
656
679
  const restartViaEcosystem = deps.restartFromEcosystem ?? restartFromEcosystem;
680
+ const spawnSyncFn = deps.spawnSyncFn ?? spawnSync;
681
+ const resolveA2aCli = deps.resolveA2aCli ?? defaultResolveA2aCli;
682
+
683
+ const parsed = parseSkillMd(ctx.skillDir);
684
+ const serviceName = parsed?.frontmatter?.lifecycle?.service?.name || `baize-${ctx.component}`;
685
+
686
+ if (serviceName === 'baize-a2a') {
687
+ const cli = resolveA2aCli(ctx.skillDir);
688
+ if (cli) {
689
+ const res = spawnSyncFn(process.execPath, [cli, 'restart', '--json'], {
690
+ input: '',
691
+ encoding: 'utf8',
692
+ timeout: A2A_CLI_RESTART_TIMEOUT_MS,
693
+ });
694
+ if (!res.error && res.status === 0) {
695
+ return { step: 8, name: 'start_service', status: 'done', message: `${serviceName} (a2a cli restart)`, duration: Date.now() - startTime };
696
+ }
697
+ }
698
+ }
657
699
 
658
700
  if (!ctx.serviceWasRunning) {
659
701
  return { step: 8, name: 'start_service', status: 'skipped', message: 'was not running', duration: Date.now() - startTime };
660
702
  }
661
703
 
662
- const parsed = parseSkillMd(ctx.skillDir);
663
- const serviceName = parsed?.frontmatter?.lifecycle?.service?.name || `baize-${ctx.component}`;
664
704
  const ecosystemPath = path.join(ctx.skillDir, 'ecosystem.config.cjs');
665
705
 
666
706
  try {