@baize-ai/core 0.2.0 → 0.3.1

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 (44) hide show
  1. package/.dockerignore +8 -18
  2. package/CHANGELOG.md +24 -1
  3. package/Dockerfile +66 -12
  4. package/README.md +85 -6
  5. package/README.zh-CN.md +63 -3
  6. package/assets/logo.png +0 -0
  7. package/cli/baize.js +6 -0
  8. package/cli/commands/a2a.js +124 -0
  9. package/cli/commands/add.js +126 -86
  10. package/cli/commands/component.js +27 -4
  11. package/cli/commands/doctor.js +2 -2
  12. package/cli/commands/init.js +28 -0
  13. package/cli/commands/self-uninstall.js +1 -1
  14. package/cli/lib/__tests__/instruction-split.test.js +1 -1
  15. package/cli/lib/a2a.js +235 -0
  16. package/cli/lib/lock.js +3 -3
  17. package/cli/lib/self-upgrade.js +1 -1
  18. package/docker-compose.yml +1 -1
  19. package/docs/docker.md +18 -4
  20. package/docs/release.md +150 -0
  21. package/package.json +1 -1
  22. package/scripts/docker-publish.sh +70 -0
  23. package/scripts/install.sh +4 -4
  24. package/scripts/pack-release.sh +361 -0
  25. package/skills/comm-bridge/package.json +7 -3
  26. package/skills/comm-bridge/scripts/c4-receive.js +23 -2
  27. package/skills/scheduler/package.json +2 -2
  28. package/skills/web-console/SKILL.md +34 -0
  29. package/skills/web-console/package.json +2 -2
  30. package/skills/web-console/public/app.js +967 -4
  31. package/skills/web-console/public/index.html +141 -0
  32. package/skills/web-console/public/styles.css +136 -0
  33. package/skills/web-console/scripts/a2a-admin.js +533 -0
  34. package/skills/web-console/scripts/channel-admin.js +99 -16
  35. package/skills/web-console/scripts/server.js +395 -1
  36. package/skills/web-console/scripts/skill-catalog.js +179 -0
  37. package/templates/claude-system.md +22 -0
  38. package/templates/pm2/ecosystem.config.cjs +4 -1
  39. package/test/a2a-cli.test.js +180 -0
  40. package/test/agent-card-api.test.js +261 -0
  41. package/test/channel-admin.test.js +87 -0
  42. package/test/component-lock.test.js +216 -0
  43. package/test/skill-catalog.test.js +118 -0
  44. package/test/web-console-routes.test.js +512 -1
@@ -59,6 +59,24 @@ import {
59
59
  uninstallChannel,
60
60
  channelAction,
61
61
  } from './channel-admin.js';
62
+ import {
63
+ getA2aStatus,
64
+ getA2aPeers,
65
+ searchA2aAgents,
66
+ enableA2a,
67
+ disableA2a,
68
+ sendA2aMessage,
69
+ submitA2aTask,
70
+ getA2aTask,
71
+ cancelA2aTask,
72
+ getA2aTaskRuns,
73
+ getA2aMessages,
74
+ installA2aTarball,
75
+ probeA2aDaemonHealthy,
76
+ resolveA2aMode,
77
+ getSchedulerTasks,
78
+ } from './a2a-admin.js';
79
+ import { listInstalledSkills } from './skill-catalog.js';
62
80
 
63
81
  const __filename = fileURLToPath(import.meta.url);
64
82
  const __dirname = path.dirname(__filename);
@@ -71,13 +89,15 @@ const PORT = process.env.WEB_CONSOLE_PORT || 3456;
71
89
 
72
90
  // Paths
73
91
  const BAIZE_DIR = process.env.BAIZE_DIR || path.join(os.homedir(), 'baize');
74
- const SKILLS_DIR = process.env.WEB_CONSOLE_SKILLS_DIR || path.join(os.homedir(), 'baize', '.claude', 'skills');
92
+ const SKILLS_DIR = process.env.WEB_CONSOLE_SKILLS_DIR || path.join(BAIZE_DIR, '.claude', 'skills');
75
93
  const DB_DIR = path.join(BAIZE_DIR, 'comm-bridge');
76
94
  const DB_PATH = path.join(DB_DIR, 'c4.db');
77
95
  const STATUS_FILE = path.join(BAIZE_DIR, 'activity-monitor', 'agent-status.json');
78
96
  const MEDIA_DIR = path.join(BAIZE_DIR, 'web-console', 'media');
79
97
  const MAX_UPLOAD_MB = Number.parseInt(process.env.WEB_CONSOLE_MAX_UPLOAD_MB || '20', 10);
80
98
  const MAX_UPLOAD_BYTES = Math.max(1, MAX_UPLOAD_MB) * 1024 * 1024;
99
+ const A2A_INSTALL_MAX_MB = Number.parseInt(process.env.WEB_CONSOLE_A2A_MAX_UPLOAD_MB || '100', 10);
100
+ const A2A_INSTALL_MAX_BYTES = Math.max(1, A2A_INSTALL_MAX_MB) * 1024 * 1024;
81
101
  const C4_SCRIPT_DIR = path.join(SKILLS_DIR, 'comm-bridge', 'scripts');
82
102
 
83
103
  // Paths - __dirname is scripts/, public/ is one level up
@@ -159,6 +179,19 @@ const upload = multer({
159
179
  }
160
180
  });
161
181
 
182
+ // A2A install uploads: a .tar.gz up to 100MB, staged in the OS temp dir and
183
+ // removed after processing (the package is moved into SKILLS_DIR/a2a).
184
+ const a2aInstallUpload = multer({
185
+ storage: multer.diskStorage({
186
+ destination: (_req, _file, cb) => cb(null, os.tmpdir()),
187
+ filename: (_req, _file, cb) => cb(null, `a2a-install-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.tgz`),
188
+ }),
189
+ limits: {
190
+ fileSize: A2A_INSTALL_MAX_BYTES,
191
+ files: 1
192
+ }
193
+ });
194
+
162
195
  // Initialize database connection
163
196
  let db;
164
197
  try {
@@ -916,6 +949,367 @@ app.post('/api/admin/channels/:name/:action', async (req, res) => {
916
949
  }
917
950
  });
918
951
 
952
+ // ── Admin: A2A (Agent-to-Agent) ─────────────────────────────────────────────
953
+ // Delegated to baize-a2a scripts/cli.js (--json); boards read local SQLite.
954
+
955
+ function sendA2aResult(res, result) {
956
+ if (result.success) {
957
+ res.json({ success: true, ...(result.json || {}) });
958
+ } else {
959
+ res.status(400).json({ success: false, error: result.error || 'a2a 命令失败' });
960
+ }
961
+ }
962
+
963
+ // Identity / registration status — D25 augments with run mode + daemon health:
964
+ // mode: CLI-reported → config.json mode → derived from enabled
965
+ // daemonHealthy: TCP connect to the configured listenPort (default 8443)
966
+ app.get('/api/admin/a2a/status', async (req, res) => {
967
+ try {
968
+ const result = await getA2aStatus();
969
+ const payload = { ...(result.json || {}) };
970
+ payload.mode = resolveA2aMode(payload.mode);
971
+ payload.daemonHealthy = await probeA2aDaemonHealthy();
972
+ if (result.success) {
973
+ res.json({ success: true, ...payload });
974
+ } else {
975
+ res.status(400).json({ success: false, error: result.error, ...payload });
976
+ }
977
+ } catch (err) {
978
+ jsonError(res, err);
979
+ }
980
+ });
981
+
982
+ // Peer directory (cached in baize-a2a)
983
+ app.get('/api/admin/a2a/peers', async (req, res) => {
984
+ try {
985
+ sendA2aResult(res, await getA2aPeers());
986
+ } catch (err) {
987
+ jsonError(res, err);
988
+ }
989
+ });
990
+
991
+ // Directory search (admin-workspace) — ?q=<query>
992
+ app.get('/api/admin/a2a/search', async (req, res) => {
993
+ try {
994
+ sendA2aResult(res, await searchA2aAgents(req.query.q));
995
+ } catch (err) {
996
+ jsonError(res, err);
997
+ }
998
+ });
999
+
1000
+ // D25 switch contract: delegate to the a2a CLI and return its {ok, mode, error?}.
1001
+ // mode: CLI-reported → config.json mode → derived from enabled.
1002
+ function sendA2aSwitchResult(res, result) {
1003
+ const json = result.json || {};
1004
+ const mode = resolveA2aMode(json.mode);
1005
+ if (result.success) {
1006
+ res.json({ ok: json.ok !== false, mode });
1007
+ } else {
1008
+ res.status(400).json({ ok: false, mode, error: result.error || 'a2a 命令失败' });
1009
+ }
1010
+ }
1011
+
1012
+ // Enable + register (generates agentId, submits registration)
1013
+ app.post('/api/admin/a2a/enable', async (req, res) => {
1014
+ try {
1015
+ sendA2aSwitchResult(res, await enableA2a(req.body || {}));
1016
+ } catch (err) {
1017
+ jsonError(res, err);
1018
+ }
1019
+ });
1020
+
1021
+ app.post('/api/admin/a2a/disable', async (req, res) => {
1022
+ try {
1023
+ sendA2aSwitchResult(res, await disableA2a());
1024
+ } catch (err) {
1025
+ jsonError(res, err);
1026
+ }
1027
+ });
1028
+
1029
+ // Send a message to a peer
1030
+ app.post('/api/admin/a2a/send', async (req, res) => {
1031
+ try {
1032
+ sendA2aResult(res, await sendA2aMessage(req.body || {}));
1033
+ } catch (err) {
1034
+ jsonError(res, err);
1035
+ }
1036
+ });
1037
+
1038
+ // Task dispatch — { peer, skill?, instruction, async? }
1039
+ app.post('/api/admin/a2a/task', async (req, res) => {
1040
+ try {
1041
+ sendA2aResult(res, await submitA2aTask(req.body || {}));
1042
+ } catch (err) {
1043
+ jsonError(res, err);
1044
+ }
1045
+ });
1046
+
1047
+ // Task board: task_runs (source = 'a2a') — registered BEFORE /task/:taskId
1048
+ app.get('/api/admin/scheduler/tasks', (req, res) => {
1049
+ try {
1050
+ res.json(getSchedulerTasks(Number.parseInt(req.query.limit, 10) || 100));
1051
+ } catch (err) {
1052
+ jsonError(res, err);
1053
+ }
1054
+ });
1055
+
1056
+ app.get('/api/admin/a2a/tasks', (req, res) => {
1057
+ try {
1058
+ res.json(getA2aTaskRuns(Number.parseInt(req.query.limit, 10) || 100));
1059
+ } catch (err) {
1060
+ jsonError(res, err);
1061
+ }
1062
+ });
1063
+
1064
+ // Task status
1065
+ app.get('/api/admin/a2a/task/:taskId', async (req, res) => {
1066
+ try {
1067
+ sendA2aResult(res, await getA2aTask(req.params.taskId));
1068
+ } catch (err) {
1069
+ jsonError(res, err);
1070
+ }
1071
+ });
1072
+
1073
+ // Task cancel
1074
+ app.post('/api/admin/a2a/task/:taskId/cancel', async (req, res) => {
1075
+ try {
1076
+ sendA2aResult(res, await cancelA2aTask(req.params.taskId));
1077
+ } catch (err) {
1078
+ jsonError(res, err);
1079
+ }
1080
+ });
1081
+
1082
+ // Message log: inbox/outbox from baize-a2a SQLite
1083
+ app.get('/api/admin/a2a/messages', (req, res) => {
1084
+ try {
1085
+ res.json(getA2aMessages(Number.parseInt(req.query.limit, 10) || 100));
1086
+ } catch (err) {
1087
+ jsonError(res, err);
1088
+ }
1089
+ });
1090
+
1091
+ // Install @baize-ai/baize-a2a from an uploaded .tar.gz (D25 私有交付).
1092
+ // multipart field `file`; ≤100MB (413 over); entries vetted against `..` /
1093
+ // absolute paths / links before extraction; manifest must be the a2a package
1094
+ // with a version; then extract to SKILLS_DIR/a2a → npm install --omit=dev →
1095
+ // components.json. Response: {ok:true, version} | {ok:false, error}.
1096
+ app.post('/api/admin/a2a/install', (req, res) => {
1097
+ a2aInstallUpload.single('file')(req, res, async (err) => {
1098
+ if (err) {
1099
+ if (err.code === 'LIMIT_FILE_SIZE') {
1100
+ return res.status(413).json({ ok: false, error: `安装包超过 ${A2A_INSTALL_MAX_MB}MB 限制` });
1101
+ }
1102
+ return res.status(400).json({ ok: false, error: `上传失败: ${err.message}` });
1103
+ }
1104
+ const file = req.file;
1105
+ if (!file) {
1106
+ return res.status(400).json({ ok: false, error: '缺少 file 字段(multipart 字段名必须为 file)' });
1107
+ }
1108
+ try {
1109
+ const result = await installA2aTarball(file.path, { skillsDir: SKILLS_DIR, originalName: file.originalname });
1110
+ res.status(result.ok ? 200 : 400).json(result);
1111
+ } catch (e) {
1112
+ res.status(500).json({ ok: false, error: `安装失败: ${e.message}` });
1113
+ } finally {
1114
+ fs.promises.rm(file.path, { force: true }).catch(() => {});
1115
+ }
1116
+ });
1117
+ });
1118
+
1119
+ // ── Admin: A2A agent card (D21 self-service) ─────────────────────────────────
1120
+ // name/description/skills are written to the same config.json the baize-a2a
1121
+ // daemon reads (configPollMs=3000 → card hot-reload). skills ids are validated
1122
+ // against the installed skill catalogue so the card cannot advertise skills
1123
+ // that do not exist on this machine.
1124
+
1125
+ function a2aConfigPath() {
1126
+ return process.env.BAIZE_A2A_CONFIG || path.join(BAIZE_DIR, 'components', 'a2a', 'config.json');
1127
+ }
1128
+
1129
+ function readAgentCard() {
1130
+ try {
1131
+ const raw = JSON.parse(fs.readFileSync(a2aConfigPath(), 'utf8'));
1132
+ return {
1133
+ name: typeof raw.name === 'string' && raw.name.trim() !== '' ? raw.name : 'Baize Agent',
1134
+ // 与 baize-a2a card.js 的默认 description 保持一致(未配置时 Card 展示默认文本)
1135
+ description: typeof raw.description === 'string' && raw.description.trim() !== '' ? raw.description : 'A Baize agent reachable over the baize-a2a protocol.',
1136
+ skills: Array.isArray(raw.skills) ? raw.skills : [],
1137
+ };
1138
+ } catch {
1139
+ return { name: 'Baize Agent', description: 'A Baize agent reachable over the baize-a2a protocol.', skills: [] };
1140
+ }
1141
+ }
1142
+
1143
+ function installedSkillMap() {
1144
+ const map = new Map();
1145
+ for (const skill of listInstalledSkills({ skillsDir: SKILLS_DIR })) {
1146
+ map.set(skill.id, skill);
1147
+ }
1148
+ return map;
1149
+ }
1150
+
1151
+ // Installed skills catalogue (source for the card skills multi-selector)
1152
+ app.get('/api/admin/a2a/card/skills', (req, res) => {
1153
+ try {
1154
+ const skills = listInstalledSkills({ skillsDir: SKILLS_DIR })
1155
+ .sort((a, b) => a.name.localeCompare(b.name));
1156
+ res.json({ success: true, skills });
1157
+ } catch (err) {
1158
+ jsonError(res, err);
1159
+ }
1160
+ });
1161
+
1162
+ // Current agent card (name/description/skills); defaults when unconfigured
1163
+ app.get('/api/admin/a2a/card', (req, res) => {
1164
+ try {
1165
+ res.json({ success: true, ...readAgentCard() });
1166
+ } catch (err) {
1167
+ jsonError(res, err);
1168
+ }
1169
+ });
1170
+
1171
+ // Save the agent card — validates, then writes config.json (preserving other
1172
+ // fields) and returns the persisted card
1173
+ app.put('/api/admin/a2a/card', (req, res) => {
1174
+ try {
1175
+ const body = req.body || {};
1176
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
1177
+ if (!name) {
1178
+ return res.status(400).json({ success: false, error: 'name 必填' });
1179
+ }
1180
+ if (name.length > 60) {
1181
+ return res.status(400).json({ success: false, error: 'name 不能超过 60 个字符' });
1182
+ }
1183
+ const description = typeof body.description === 'string' ? body.description : '';
1184
+ if (description.length > 300) {
1185
+ return res.status(400).json({ success: false, error: 'description 不能超过 300 个字符' });
1186
+ }
1187
+ const requested = Array.isArray(body.skills) ? body.skills : [];
1188
+ const installed = installedSkillMap();
1189
+ const ids = [];
1190
+ const seen = new Set();
1191
+ for (const item of requested) {
1192
+ const id = typeof item === 'string' ? item : (item && typeof item.id === 'string' ? item.id : '');
1193
+ if (!id || !installed.has(id)) {
1194
+ return res.status(400).json({
1195
+ success: false,
1196
+ error: `skills 包含未安装的 skill:${id || '(空)'}`,
1197
+ });
1198
+ }
1199
+ if (seen.has(id)) {
1200
+ return res.status(400).json({ success: false, error: `skills 包含重复的 skill:${id}` });
1201
+ }
1202
+ seen.add(id);
1203
+ ids.push(id);
1204
+ }
1205
+ // Persist full SkillInfo entries ({id,name,description}) so baize-a2a's
1206
+ // card.js serves a complete card without any daemon-side change.
1207
+ const skills = ids.map((id) => {
1208
+ const info = installed.get(id);
1209
+ return { id, name: info.name, description: info.description };
1210
+ });
1211
+ const file = a2aConfigPath();
1212
+ let cfg = {};
1213
+ try {
1214
+ cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
1215
+ } catch {
1216
+ // missing/corrupt — start from the contract defaults
1217
+ cfg = { enabled: false, adminUrl: '', agentId: '', advertiseUrl: '', cert: { keyPath: '', certPath: '' } };
1218
+ }
1219
+ cfg.name = name;
1220
+ cfg.description = description;
1221
+ cfg.skills = skills;
1222
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1223
+ fs.writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`);
1224
+ res.json({ success: true, name, description, skills });
1225
+ } catch (err) {
1226
+ jsonError(res, err);
1227
+ }
1228
+ });
1229
+
1230
+ // ── Admin: A2A call authorization (D22 单元 C) ───────────────────────────────
1231
+ // Authz policy persisted in the same config.json the baize-a2a daemon reads:
1232
+ // { mode: 'open' | 'allowlist', allow: [...], block: [...] }
1233
+ // - open (default): accept every approved agent except the block list
1234
+ // - allowlist: only the allow list may call
1235
+ const AUTHZ_MODES = new Set(['open', 'allowlist']);
1236
+
1237
+ function defaultAuthz() {
1238
+ return { mode: 'open', allow: [], block: [] };
1239
+ }
1240
+
1241
+ function readAuthz() {
1242
+ try {
1243
+ const raw = JSON.parse(fs.readFileSync(a2aConfigPath(), 'utf8'));
1244
+ const authz = raw && typeof raw.authz === 'object' && !Array.isArray(raw.authz) ? raw.authz : {};
1245
+ const clean = (list) => (Array.isArray(list) ? list.filter((x) => typeof x === 'string' && x.trim() !== '') : []);
1246
+ return {
1247
+ mode: AUTHZ_MODES.has(authz.mode) ? authz.mode : 'open',
1248
+ allow: clean(authz.allow),
1249
+ block: clean(authz.block),
1250
+ };
1251
+ } catch {
1252
+ return defaultAuthz();
1253
+ }
1254
+ }
1255
+
1256
+ // Validates an allow/block list; returns { list } or { error }.
1257
+ function validateAuthzList(value, name) {
1258
+ if (!Array.isArray(value)) return { error: `${name} 必须是数组` };
1259
+ const seen = new Set();
1260
+ const list = [];
1261
+ for (const item of value) {
1262
+ if (typeof item !== 'string' || item.trim() === '') {
1263
+ return { error: `${name} 包含空项(必须是非空字符串)` };
1264
+ }
1265
+ const v = item.trim();
1266
+ if (seen.has(v)) return { error: `${name} 包含重复的 agent_id:${v}` };
1267
+ seen.add(v);
1268
+ list.push(v);
1269
+ }
1270
+ return { list };
1271
+ }
1272
+
1273
+ // Current authz policy (config.json authz; contract defaults when unconfigured)
1274
+ app.get('/api/admin/a2a/authz', (req, res) => {
1275
+ try {
1276
+ res.json({ success: true, ...readAuthz() });
1277
+ } catch (err) {
1278
+ jsonError(res, err);
1279
+ }
1280
+ });
1281
+
1282
+ // Save the authz policy — validates, then writes config.json (preserving other
1283
+ // fields) and returns the persisted policy
1284
+ app.put('/api/admin/a2a/authz', (req, res) => {
1285
+ try {
1286
+ const body = req.body || {};
1287
+ const mode = body.mode;
1288
+ if (mode !== 'open' && mode !== 'allowlist') {
1289
+ return res.status(400).json({ success: false, error: 'mode 必须是 open 或 allowlist' });
1290
+ }
1291
+ const allow = body.allow === undefined ? { list: [] } : validateAuthzList(body.allow, 'allow');
1292
+ if (allow.error) return res.status(400).json({ success: false, error: allow.error });
1293
+ const block = body.block === undefined ? { list: [] } : validateAuthzList(body.block, 'block');
1294
+ if (block.error) return res.status(400).json({ success: false, error: block.error });
1295
+ const authz = { mode, allow: allow.list, block: block.list };
1296
+ const file = a2aConfigPath();
1297
+ let cfg = {};
1298
+ try {
1299
+ cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
1300
+ } catch {
1301
+ // missing/corrupt — start from the contract defaults
1302
+ cfg = { enabled: false, adminUrl: '', agentId: '', advertiseUrl: '', cert: { keyPath: '', certPath: '' } };
1303
+ }
1304
+ cfg.authz = authz;
1305
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1306
+ fs.writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`);
1307
+ res.json({ success: true, ...authz });
1308
+ } catch (err) {
1309
+ jsonError(res, err);
1310
+ }
1311
+ });
1312
+
919
1313
  // Serve index.html for root
920
1314
  app.get('/', (req, res) => {
921
1315
  res.sendFile(path.join(SKILL_ROOT, 'public', 'index.html'));
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Skill catalogue for the web console (D21: agent card self-service).
3
+ *
4
+ * Reads the installed skill list from the Claude skills directory
5
+ * (~/baize/.claude/skills by default; BAIZE_DIR env aware). Each subdirectory
6
+ * with a SKILL.md contributes one entry:
7
+ *
8
+ * { id: <directory name>, name, description }
9
+ *
10
+ * name/description are extracted from the SKILL.md frontmatter (the YAML block
11
+ * between the leading `---` and its closing `---`). Supported description
12
+ * shapes: plain scalar, `>-` folded, `|-` literal, and plain multi-line
13
+ * continuations. Skills whose frontmatter fails to parse or that lack a
14
+ * SKILL.md are skipped.
15
+ *
16
+ * web-console is deployed as a standalone skill, so it cannot import cli/lib —
17
+ * this is a deliberately small, dependency-free YAML-frontmatter reader.
18
+ */
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import os from 'node:os';
23
+
24
+ /**
25
+ * Resolve the installed-skills directory. Honors BAIZE_DIR like the rest of
26
+ * the web-console; falls back to ~/baize/.claude/skills.
27
+ */
28
+ export function defaultSkillsDir() {
29
+ const base = process.env.BAIZE_DIR || path.join(os.homedir(), 'baize');
30
+ return path.join(base, '.claude', 'skills');
31
+ }
32
+
33
+ /**
34
+ * Parse SKILL.md frontmatter into { name, description }.
35
+ * Returns null when the file has no frontmatter block or no usable name.
36
+ *
37
+ * @param {string} content - raw SKILL.md file content
38
+ */
39
+ export function parseSkillFrontmatter(content) {
40
+ if (typeof content !== 'string') return null;
41
+ const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(content);
42
+ if (!match) return null;
43
+
44
+ const lines = match[1].split(/\r?\n/);
45
+ const scalars = {};
46
+ let current = null; // { key, style, rest, lines[] }
47
+
48
+ const flush = () => {
49
+ if (!current) return;
50
+ const text = renderScalar(current);
51
+ if (text !== null && (current.key === 'name' || current.key === 'description')) {
52
+ scalars[current.key] = text;
53
+ }
54
+ current = null;
55
+ };
56
+
57
+ for (const line of lines) {
58
+ if (current) {
59
+ // Continuation lines: blank or indented relative to the key.
60
+ if (line.trim() === '' || /^\s+/.test(line)) {
61
+ current.lines.push(line);
62
+ continue;
63
+ }
64
+ // A non-indented, non-blank line ends the current scalar (next key or
65
+ // stray content). Only top-level `key:` lines restart a scalar.
66
+ flush();
67
+ }
68
+ const keyMatch = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
69
+ if (keyMatch) {
70
+ const [, key, rest] = keyMatch;
71
+ const blockMatch = /^([>|])(-)?$/.exec(rest.trim());
72
+ current = {
73
+ key,
74
+ style: blockMatch ? (blockMatch[1] === '>' ? 'folded' : 'literal') : 'plain',
75
+ chomp: blockMatch ? Boolean(blockMatch[2]) : false,
76
+ rest,
77
+ lines: [],
78
+ };
79
+ }
80
+ }
81
+ flush();
82
+
83
+ if (typeof scalars.name !== 'string' || scalars.name.trim() === '') return null;
84
+ return {
85
+ name: scalars.name.trim(),
86
+ description: typeof scalars.description === 'string' ? scalars.description : '',
87
+ };
88
+ }
89
+
90
+ /** Assemble a scalar value from its collected continuation lines. */
91
+ function renderScalar({ key, style, chomp, rest, lines }) {
92
+ if (style === 'plain') {
93
+ const parts = [];
94
+ if (rest.trim() !== '') parts.push(rest.trim());
95
+ for (const line of lines) {
96
+ if (line.trim() !== '') parts.push(line.trim());
97
+ }
98
+ if (parts.length === 0) return '';
99
+ return dequote(parts.join(' '));
100
+ }
101
+ // Folded / literal block scalars: value lives entirely on indented lines.
102
+ if (style === 'folded') {
103
+ const paragraphs = [];
104
+ let para = [];
105
+ for (const line of lines) {
106
+ if (line.trim() === '') {
107
+ if (para.length) {
108
+ paragraphs.push(para.join(' '));
109
+ para = [];
110
+ }
111
+ } else {
112
+ para.push(line.trim());
113
+ }
114
+ }
115
+ if (para.length) paragraphs.push(para.join(' '));
116
+ return paragraphs.join('\n');
117
+ }
118
+ // literal: strip the common indentation shared by content lines
119
+ let text = stripCommonIndent(lines).join('\n');
120
+ return chomp ? text.replace(/\n+$/, '') : text;
121
+ }
122
+
123
+ /** Remove the smallest leading indentation shared by non-blank lines. */
124
+ function stripCommonIndent(lines) {
125
+ const nonBlank = lines.filter((line) => line.trim() !== '');
126
+ if (nonBlank.length === 0) return lines;
127
+ let min = Infinity;
128
+ for (const line of nonBlank) {
129
+ const m = /^[ \t]*/.exec(line);
130
+ min = Math.min(min, m[0].length);
131
+ }
132
+ if (!Number.isFinite(min) || min === 0) return lines;
133
+ return lines.map((line) => (line.trim() === '' ? '' : line.slice(min)));
134
+ }
135
+
136
+ /** Strip a single pair of wrapping quotes (YAML double/single). */
137
+ function dequote(value) {
138
+ if (value.length >= 2) {
139
+ const first = value[0];
140
+ const last = value[value.length - 1];
141
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
142
+ return value.slice(1, -1);
143
+ }
144
+ }
145
+ return value;
146
+ }
147
+
148
+ /**
149
+ * List installed skills as [{ id, name, description }].
150
+ * Entries are returned in directory order; sorting is the caller's job.
151
+ *
152
+ * @param {{ skillsDir?: string }} [options]
153
+ */
154
+ export function listInstalledSkills({ skillsDir = defaultSkillsDir() } = {}) {
155
+ let entries;
156
+ try {
157
+ entries = fs.readdirSync(skillsDir, { withFileTypes: true });
158
+ } catch {
159
+ return [];
160
+ }
161
+ const skills = [];
162
+ for (const entry of entries) {
163
+ if (!entry.isDirectory()) continue;
164
+ let content;
165
+ try {
166
+ content = fs.readFileSync(path.join(skillsDir, entry.name, 'SKILL.md'), 'utf8');
167
+ } catch {
168
+ continue; // no SKILL.md (or unreadable) — skip
169
+ }
170
+ const parsed = parseSkillFrontmatter(content);
171
+ if (!parsed) continue;
172
+ skills.push({
173
+ id: entry.name,
174
+ name: parsed.name,
175
+ description: parsed.description || '',
176
+ });
177
+ }
178
+ return skills;
179
+ }
@@ -125,6 +125,28 @@ to you when idle. When a scheduled task arrives, or when you want to schedule
125
125
  one, read `~/baize/.claude/skills/scheduler/SKILL.md` first if you have not
126
126
  already this session.
127
127
 
128
+ ## A2A & Agent Discovery
129
+
130
+ A2A lets you collaborate with other Baize agents: list, search, inspect, and
131
+ delegate to them over HTTPS (each agent is an independent instance with its
132
+ own skills and capabilities).
133
+
134
+ **When to use**: the user needs another agent's capability, asks about
135
+ internal agents ("what agents are available", "who can do X"), or asks you to
136
+ collaborate with / delegate to another agent.
137
+
138
+ **How** (data is always queried live — never answer from memory):
139
+ 1. `baize a2a session-summary` — list the currently available agents (latest data)
140
+ 2. `baize a2a search <query>` — find agents whose card matches the need (e.g.
141
+ `a2a search "crypto price trend"`)
142
+ 3. `baize a2a card <agent-id>` — inspect one agent's capabilities in detail
143
+ 4. `baize a2a task <agent-id> <skill> <instruction>` — delegate a task (waits for
144
+ the result by default; `--async` returns immediately)
145
+ 5. `baize a2a task status <task-id>` / `baize a2a task cancel <task-id>` — track/cancel
146
+
147
+ Read `~/baize/.claude/skills/a2a/SKILL.md` once if you have not already this
148
+ session for the full protocol.
149
+
128
150
  ## Skills & Components
129
151
 
130
152
  - **Sedimenting a reusable workflow for the user** (a repeatable capability,
@@ -208,7 +208,10 @@ module.exports = {
208
208
  cwd: HOME,
209
209
  env: {
210
210
  PATH: ENHANCED_PATH,
211
- NODE_ENV: 'production'
211
+ NODE_ENV: 'production',
212
+ // Docker/container 场景:端口映射需监听 0.0.0.0(默认 127.0.0.1 只通容器内 loopback)。
213
+ // .env 的 WEB_CONSOLE_BIND 不被 PM2 自动加载,必须在 ecosystem 注入。
214
+ WEB_CONSOLE_BIND: process.env.WEB_CONSOLE_BIND || '0.0.0.0'
212
215
  },
213
216
  autorestart: true,
214
217
  max_restarts: 10,