@gajae-gateway/cli 0.1.7 → 0.2.0

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/dist/main.js CHANGED
@@ -2,11 +2,63 @@
2
2
  // @bun
3
3
 
4
4
  // src/main.ts
5
- import { Database } from "bun:sqlite";
6
- import { copyFile as copyFile2, lstat as lstat2, readFile as readFile8, stat as stat3 } from "fs/promises";
7
- import { isAbsolute, join as join10 } from "path";
8
- import { LOOPBACK_ORIGIN, originKey as originKey2 } from "@gajae-gateway/protocol";
5
+ import { isAbsolute } from "path";
6
+ import { LOOPBACK_ORIGIN, originKey as originKey2, WORK_JOBS_MAX_LIMIT } from "@gajae-gateway/protocol";
9
7
  import { GajaewayClient } from "@gajae-gateway/sdk";
8
+ // package.json
9
+ var package_default = {
10
+ name: "@gajae-gateway/cli",
11
+ version: "0.2.0",
12
+ type: "module",
13
+ description: "Owner CLI for the gajaeway gateway: status, sessions, chat, monitors, and worker lanes over the gateway socket. Requires the Bun runtime (uses Bun.file/Bun.spawn).",
14
+ license: "AGPL-3.0-only",
15
+ author: "Yeachan Heo",
16
+ repository: {
17
+ type: "git",
18
+ url: "git+https://github.com/Yeachan-Heo/gajae-way.git",
19
+ directory: "packages/cli"
20
+ },
21
+ homepage: "https://github.com/Yeachan-Heo/gajae-way/tree/main/packages/cli#readme",
22
+ bugs: "https://github.com/Yeachan-Heo/gajae-way/issues",
23
+ keywords: [
24
+ "gajaeway",
25
+ "cli",
26
+ "gateway"
27
+ ],
28
+ files: [
29
+ "dist",
30
+ "src"
31
+ ],
32
+ main: "./dist/main.js",
33
+ types: "./src/main.ts",
34
+ exports: {
35
+ ".": {
36
+ bun: "./src/main.ts",
37
+ types: "./src/main.ts",
38
+ default: "./dist/main.js"
39
+ }
40
+ },
41
+ bin: {
42
+ gajaeway: "./dist/main.js"
43
+ },
44
+ publishConfig: {
45
+ access: "public"
46
+ },
47
+ scripts: {
48
+ build: "rm -rf dist && bun build src/main.ts --outdir dist --target bun --format esm --external @gajae-gateway/protocol --external @gajae-gateway/sdk && tsc -p tsconfig.build.json",
49
+ prepublishOnly: "bun run build"
50
+ },
51
+ dependencies: {
52
+ "@gajae-gateway/sdk": "workspace:*",
53
+ "@gajae-gateway/protocol": "workspace:*"
54
+ },
55
+ engines: {
56
+ bun: ">=1.4.0"
57
+ },
58
+ devDependencies: {
59
+ "@gajae-gateway/home": "workspace:*"
60
+ }
61
+ };
10
62
 
11
63
  // src/list.ts
12
64
  import { originKey } from "@gajae-gateway/protocol";
@@ -145,11 +197,203 @@ function renderList(columns, rows, options, envelope) {
145
197
  }
146
198
 
147
199
  // src/migrate.ts
148
- import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
149
- import { join as join4 } from "path";
200
+ import { randomUUID } from "crypto";
201
+ import { chmod, mkdir as mkdir2, readFile as readFile3, rename, rm, writeFile } from "fs/promises";
202
+ import { join as join5 } from "path";
150
203
 
204
+ // ../home/src/layout.ts
205
+ import { join } from "path";
206
+ function homeLayout(home) {
207
+ const leasesDir = join(home, "leases");
208
+ const brokerDir = join(home, "broker");
209
+ const brokerProvisionDir = join(brokerDir, "provision");
210
+ const gjcPrefix = join(home, "gjc");
211
+ const gjcCurrent = join(gjcPrefix, "current");
212
+ return {
213
+ home,
214
+ leasesDir,
215
+ lease: (name) => join(leasesDir, name),
216
+ brokerAgentDir: join(brokerDir, "agent"),
217
+ brokerProvisionDir,
218
+ brokerProvisionManifest: join(brokerProvisionDir, "manifest.json"),
219
+ backupsDir: join(home, "backups"),
220
+ gjcPrefix,
221
+ gjcCurrent,
222
+ gjcExecutable: (version) => join(version === undefined ? gjcCurrent : join(gjcPrefix, version), "bin", "gjc"),
223
+ socket: join(home, "gateway.sock"),
224
+ db: join(home, "gateway.db")
225
+ };
226
+ }
227
+ // ../home/src/lease.ts
228
+ import { dlopen, FFIType, read } from "bun:ffi";
229
+ import { constants } from "fs";
230
+ import { mkdir, open, readFile } from "fs/promises";
231
+ var LOCK_EX = 2;
232
+ var LOCK_NB = 4;
233
+ var F_GETFD = 1;
234
+ var FD_CLOEXEC = 1;
235
+ var MAX_OWNER_BYTES = 4096;
236
+ var PLATFORMS = {
237
+ darwin: { library: "/usr/lib/libSystem.B.dylib", errnoSymbol: "__error", oCloexec: 16777216, ewouldblock: 35 },
238
+ linux: { library: "libc.so.6", errnoSymbol: "__errno_location", oCloexec: 524288, ewouldblock: 11 }
239
+ };
240
+ var libc;
241
+ var heldHandles = new Set;
242
+ function loadLibc(name) {
243
+ if (libc)
244
+ return libc;
245
+ const platform = PLATFORMS[process.platform];
246
+ if (!platform)
247
+ throw new LeaseError("lease_unsupported", name, "dlopen", process.platform);
248
+ const symbols = {
249
+ flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
250
+ fcntl: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
251
+ [platform.errnoSymbol]: { args: [], returns: FFIType.ptr }
252
+ };
253
+ const library = dlopen(platform.library, symbols);
254
+ const errnoLocation = library.symbols[platform.errnoSymbol];
255
+ libc = {
256
+ platform,
257
+ flock: (fd, operation) => library.symbols.flock(fd, operation),
258
+ getfd: (fd) => library.symbols.fcntl(fd, F_GETFD),
259
+ errno: () => read.i32(errnoLocation(), 0)
260
+ };
261
+ return libc;
262
+ }
263
+
264
+ class LeaseHeldError extends Error {
265
+ name;
266
+ owner;
267
+ code = "lease_held";
268
+ constructor(name, owner) {
269
+ super(`lease_held name=${name}${owner ? ` owner_pid=${owner.pid} since=${owner.startedAt}` : ""}`);
270
+ this.name = name;
271
+ this.owner = owner;
272
+ }
273
+ }
274
+
275
+ class LeaseError extends Error {
276
+ code;
277
+ name;
278
+ syscall;
279
+ errno;
280
+ constructor(code, name, syscall, errno) {
281
+ super(`${code} name=${name} syscall=${syscall} errno=${errno}`);
282
+ this.code = code;
283
+ this.name = name;
284
+ this.syscall = syscall;
285
+ this.errno = errno;
286
+ }
287
+ }
288
+ async function openLeaseFile(path, name, lib, create) {
289
+ const flags = constants.O_RDWR | constants.O_NOFOLLOW | lib.platform.oCloexec | (create ? constants.O_CREAT : 0);
290
+ let handle;
291
+ try {
292
+ handle = await open(path, flags, 384);
293
+ } catch (error) {
294
+ const code = error.code ?? String(error);
295
+ throw new LeaseError("lease_failed", name, "open", code);
296
+ }
297
+ try {
298
+ const info = await handle.stat();
299
+ if (!info.isFile() || info.nlink !== 1)
300
+ throw new LeaseError("lease_failed", name, "fstat", "not_regular_file");
301
+ const fdFlags = lib.getfd(handle.fd);
302
+ if (fdFlags < 0 || (fdFlags & FD_CLOEXEC) === 0)
303
+ throw new LeaseError("lease_failed", name, "fcntl", fdFlags < 0 ? lib.errno() : "cloexec_missing");
304
+ return handle;
305
+ } catch (error) {
306
+ await handle.close();
307
+ throw error;
308
+ }
309
+ }
310
+ function tryLock(handle, name, lib) {
311
+ if (lib.flock(handle.fd, LOCK_EX | LOCK_NB) === 0)
312
+ return true;
313
+ const errno = lib.errno();
314
+ if (errno === lib.platform.ewouldblock)
315
+ return false;
316
+ throw new LeaseError("lease_failed", name, "flock", errno);
317
+ }
318
+ function parseOwner(raw) {
319
+ if (!raw.trim() || raw.length > MAX_OWNER_BYTES)
320
+ return;
321
+ try {
322
+ const value = JSON.parse(raw);
323
+ if (Number.isSafeInteger(value.pid) && typeof value.token === "string" && typeof value.startedAt === "string")
324
+ return { pid: value.pid, token: value.token, startedAt: value.startedAt };
325
+ } catch {}
326
+ return;
327
+ }
328
+ async function readLeaseOwner(home, name) {
329
+ try {
330
+ return parseOwner(await readFile(homeLayout(home).lease(name), "utf8"));
331
+ } catch {
332
+ return;
333
+ }
334
+ }
335
+ async function leaseHeld(home, name) {
336
+ const lib = loadLibc(name);
337
+ let handle;
338
+ try {
339
+ handle = await openLeaseFile(homeLayout(home).lease(name), name, lib, false);
340
+ } catch (error) {
341
+ if (error instanceof LeaseError && error.syscall === "open" && error.errno === "ENOENT")
342
+ return false;
343
+ throw error;
344
+ }
345
+ try {
346
+ return !tryLock(handle, name, lib);
347
+ } finally {
348
+ await handle.close();
349
+ }
350
+ }
351
+ async function acquireLease(home, name) {
352
+ const lib = loadLibc(name);
353
+ const layout = homeLayout(home);
354
+ try {
355
+ await mkdir(layout.leasesDir, { recursive: true, mode: 448 });
356
+ } catch (error) {
357
+ throw new LeaseError("lease_failed", name, "mkdir", error.code ?? String(error));
358
+ }
359
+ const path = layout.lease(name);
360
+ const handle = await openLeaseFile(path, name, lib, true);
361
+ try {
362
+ if (!tryLock(handle, name, lib))
363
+ throw new LeaseHeldError(name, await readLeaseOwner(home, name));
364
+ const owner = { pid: process.pid, token: crypto.randomUUID(), startedAt: new Date().toISOString() };
365
+ const bytes = Buffer.from(`${JSON.stringify(owner)}
366
+ `);
367
+ try {
368
+ await handle.truncate(0);
369
+ let offset = 0;
370
+ while (offset < bytes.length) {
371
+ const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset, offset);
372
+ if (bytesWritten === 0)
373
+ throw new Error("short write");
374
+ offset += bytesWritten;
375
+ }
376
+ await handle.sync();
377
+ } catch (error) {
378
+ throw new LeaseError("lease_failed", name, "write", error.code ?? String(error));
379
+ }
380
+ heldHandles.add(handle);
381
+ let released = false;
382
+ const release = async () => {
383
+ if (released)
384
+ return;
385
+ released = true;
386
+ heldHandles.delete(handle);
387
+ await handle.close();
388
+ };
389
+ return { name, token: owner.token, path, release, [Symbol.asyncDispose]: release };
390
+ } catch (error) {
391
+ await handle.close();
392
+ throw error;
393
+ }
394
+ }
151
395
  // src/migrate/hermes.ts
152
- import { join as join2 } from "path";
396
+ import { join as join3 } from "path";
153
397
 
154
398
  // src/migrate/plan.ts
155
399
  import { eventTypeOrigin, validateOriginRef } from "@gajae-gateway/protocol";
@@ -385,9 +629,9 @@ function emitImportedAxis(extract) {
385
629
  }
386
630
 
387
631
  // src/migrate/source-fs.ts
388
- import { lstat, readdir, readFile } from "fs/promises";
632
+ import { lstat, readdir, readFile as readFile2 } from "fs/promises";
389
633
  import { homedir } from "os";
390
- import { join, relative } from "path";
634
+ import { join as join2, relative } from "path";
391
635
 
392
636
  class SourceTree {
393
637
  root;
@@ -419,14 +663,14 @@ class SourceTree {
419
663
  if (!info)
420
664
  return;
421
665
  this.consume(path);
422
- return readFile(path, "utf8");
666
+ return readFile2(path, "utf8");
423
667
  }
424
668
  async bytes(path) {
425
669
  const info = await regularFile(path);
426
670
  if (!info)
427
671
  return;
428
672
  this.consume(path);
429
- return new Uint8Array(await readFile(path));
673
+ return new Uint8Array(await readFile2(path));
430
674
  }
431
675
  }
432
676
  async function regularFile(path) {
@@ -454,16 +698,16 @@ async function entries(path) {
454
698
  async function walkFiles(dir, prefix = "") {
455
699
  const out = [];
456
700
  for (const name of await entries(dir)) {
457
- const full = join(dir, name);
701
+ const full = join2(dir, name);
458
702
  const info = await lstat(full).catch(() => {
459
703
  return;
460
704
  });
461
705
  if (!info)
462
706
  continue;
463
707
  if (info.isDirectory())
464
- out.push(...await walkFiles(full, join(prefix, name)));
708
+ out.push(...await walkFiles(full, join2(prefix, name)));
465
709
  else if (info.isFile())
466
- out.push(join(prefix, name));
710
+ out.push(join2(prefix, name));
467
711
  }
468
712
  return out;
469
713
  }
@@ -487,7 +731,7 @@ function parseDotenv(text) {
487
731
  return values;
488
732
  }
489
733
  function expandHome(path) {
490
- return path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
734
+ return path === "~" ? homedir() : path.startsWith("~/") ? join2(homedir(), path.slice(2)) : path;
491
735
  }
492
736
  function isRecord(value) {
493
737
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -502,7 +746,7 @@ var HERMES_ENTRY_DELIMITER = `
502
746
  \xA7
503
747
  `;
504
748
  async function detectHermes(root) {
505
- return await regularFile(join2(root, "config.yaml")) !== undefined || await isDirectory(join2(root, "memories")) || await regularFile(join2(root, "cron", "jobs.json")) !== undefined;
749
+ return await regularFile(join3(root, "config.yaml")) !== undefined || await isDirectory(join3(root, "memories")) || await regularFile(join3(root, "cron", "jobs.json")) !== undefined;
506
750
  }
507
751
  var TOKEN_KEYS = {
508
752
  DISCORD_BOT_TOKEN: "discord",
@@ -520,7 +764,7 @@ async function readHermes(tree, home) {
520
764
  const root = tree.root;
521
765
  const extract = emptyExtract("hermes", root);
522
766
  for (const name of ["SOUL.md", "AGENTS.md"]) {
523
- const path = join2(root, name);
767
+ const path = join3(root, name);
524
768
  const text = await tree.text(path);
525
769
  if (text === undefined)
526
770
  continue;
@@ -534,8 +778,8 @@ async function readHermes(tree, home) {
534
778
  mtime: info?.mtime
535
779
  });
536
780
  }
537
- const memoryDir = join2(root, "memories");
538
- const userPath = join2(memoryDir, "USER.md");
781
+ const memoryDir = join3(root, "memories");
782
+ const userPath = join3(memoryDir, "USER.md");
539
783
  const userText = await tree.text(userPath);
540
784
  if (userText !== undefined) {
541
785
  const info = await regularFile(userPath);
@@ -548,7 +792,7 @@ async function readHermes(tree, home) {
548
792
  mtime: info?.mtime
549
793
  });
550
794
  }
551
- const memoryPath = join2(memoryDir, "MEMORY.md");
795
+ const memoryPath = join3(memoryDir, "MEMORY.md");
552
796
  const memoryText = await tree.text(memoryPath);
553
797
  if (memoryText !== undefined) {
554
798
  const info = await regularFile(memoryPath);
@@ -560,13 +804,12 @@ async function readHermes(tree, home) {
560
804
  content: entriesToMarkdown("Long-term memory (imported from Hermes)", memoryText),
561
805
  mtime: info?.mtime
562
806
  });
563
- emitImportedAxis(extract);
564
807
  }
565
- const envPath = join2(root, ".env");
808
+ const envPath = join3(root, ".env");
566
809
  const envText = await tree.text(envPath);
567
810
  const env = envText === undefined ? new Map : parseDotenv(envText);
568
811
  const consumedEnv = new Set;
569
- const configPath = join2(root, "config.yaml");
812
+ const configPath = join3(root, "config.yaml");
570
813
  const configText = await tree.text(configPath);
571
814
  let config = {};
572
815
  if (configText !== undefined) {
@@ -693,7 +936,7 @@ function readHermesChannelPolicy(config, env, channels, consumedConfig, consumed
693
936
  }
694
937
  }
695
938
  async function readHermesCron(tree, env, consumedEnv, extract) {
696
- const path = join2(tree.root, "cron", "jobs.json");
939
+ const path = join3(tree.root, "cron", "jobs.json");
697
940
  const text = await tree.text(path);
698
941
  if (text === undefined)
699
942
  return;
@@ -793,11 +1036,11 @@ function hermesTarget(job, env, consumedEnv, extract, source) {
793
1036
  return resolved[0];
794
1037
  }
795
1038
  async function readHermesSkills(tree, extract) {
796
- const skillsDir = join2(tree.root, "skills");
1039
+ const skillsDir = join3(tree.root, "skills");
797
1040
  if (!await isDirectory(skillsDir))
798
1041
  return;
799
1042
  tree.consume(skillsDir);
800
- const bundledText = await tree.text(join2(skillsDir, ".bundled_manifest"));
1043
+ const bundledText = await tree.text(join3(skillsDir, ".bundled_manifest"));
801
1044
  const bundled = new Set((bundledText ?? "").split(/\r?\n/).map((line) => line.split(":")[0]?.trim() ?? "").filter(Boolean));
802
1045
  const files = (await walkFiles(skillsDir)).filter((file) => !file.split("/").some((part) => part.startsWith(".")));
803
1046
  const skillRoots = files.filter((file) => file.endsWith("/SKILL.md") || file === "SKILL.md").map((file) => file.slice(0, -"/SKILL.md".length));
@@ -806,7 +1049,7 @@ async function readHermesSkills(tree, extract) {
806
1049
  const name = skillRoot.split("/").at(-1) ?? skillRoot;
807
1050
  if (bundled.has(name))
808
1051
  continue;
809
- await copySkill(tree, join2(skillsDir, skillRoot), name, extract, seen);
1052
+ await copySkill(tree, join3(skillsDir, skillRoot), name, extract, seen);
810
1053
  }
811
1054
  if (bundled.size)
812
1055
  extract.warnings.push(`${bundled.size} Hermes-bundled skill(s) skipped; they ship with Hermes, not with your data`);
@@ -824,7 +1067,7 @@ async function copySkill(tree, dir, name, extract, seen) {
824
1067
  }
825
1068
  seen.add(name);
826
1069
  for (const file of await walkFiles(dir)) {
827
- const path = join2(dir, file);
1070
+ const path = join3(dir, file);
828
1071
  const content = await tree.bytes(path);
829
1072
  if (content === undefined)
830
1073
  continue;
@@ -839,17 +1082,17 @@ async function copySkill(tree, dir, name, extract, seen) {
839
1082
  }
840
1083
 
841
1084
  // src/migrate/openclaw.ts
842
- import { join as join3 } from "path";
1085
+ import { join as join4 } from "path";
843
1086
  var OPENCLAW_CONFIG = "config.json";
844
1087
  async function detectOpenClaw(root) {
845
- const configPath = join3(root, OPENCLAW_CONFIG);
1088
+ const configPath = join4(root, OPENCLAW_CONFIG);
846
1089
  const stat = await regularFile(configPath);
847
1090
  return stat !== undefined;
848
1091
  }
849
1092
  async function readOpenClaw(tree, _home) {
850
1093
  const extract = emptyExtract("openclaw", tree.root);
851
- const configPath = join3(tree.root, OPENCLAW_CONFIG);
852
- const envPath = join3(tree.root, ".env");
1094
+ const configPath = join4(tree.root, OPENCLAW_CONFIG);
1095
+ const envPath = join4(tree.root, ".env");
853
1096
  let config = {};
854
1097
  const configText = await tree.text(configPath);
855
1098
  if (configText) {
@@ -999,25 +1242,25 @@ function buildOpenClawMonitor(name, spec) {
999
1242
  });
1000
1243
  }
1001
1244
  async function readOpenClawSkills(tree, skillsConfig, extract) {
1002
- const skillsDir = join3(tree.root, "skills");
1245
+ const skillsDir = join4(tree.root, "skills");
1003
1246
  const hasSkillsDir = await isDirectory(skillsDir);
1004
1247
  for (const [skillName] of Object.entries(skillsConfig)) {
1005
1248
  if (!hasSkillsDir)
1006
1249
  continue;
1007
- const skillPath = join3(skillsDir, `${skillName}.md`);
1250
+ const skillPath = join4(skillsDir, `${skillName}.md`);
1008
1251
  const skillContent = await tree.text(skillPath);
1009
1252
  if (skillContent) {
1010
1253
  extract.files.push({
1011
1254
  category: "skills",
1012
1255
  sources: [skillPath],
1013
- target: join3(".gjc", "skills", `${slug(skillName)}.md`),
1256
+ target: join4(".gjc", "skills", `${slug(skillName)}.md`),
1014
1257
  content: skillContent
1015
1258
  });
1016
1259
  }
1017
1260
  }
1018
1261
  }
1019
1262
  async function readOpenClawMemory(tree, _memoryConfig, extract) {
1020
- const memoryDir = join3(tree.root, "memory");
1263
+ const memoryDir = join4(tree.root, "memory");
1021
1264
  const hasMemoryDir = await isDirectory(memoryDir);
1022
1265
  if (!hasMemoryDir) {
1023
1266
  return;
@@ -1025,13 +1268,13 @@ async function readOpenClawMemory(tree, _memoryConfig, extract) {
1025
1268
  const files = await walkFiles(memoryDir);
1026
1269
  for (const file of files) {
1027
1270
  if (file.endsWith(".md")) {
1028
- const fullPath = join3(memoryDir, file);
1271
+ const fullPath = join4(memoryDir, file);
1029
1272
  const content = await tree.text(fullPath);
1030
1273
  if (content) {
1031
1274
  extract.files.push({
1032
1275
  category: "memory",
1033
1276
  sources: [fullPath],
1034
- target: join3("memory", "imported.md"),
1277
+ target: join4("memory", "imported.md"),
1035
1278
  content
1036
1279
  });
1037
1280
  }
@@ -1041,8 +1284,25 @@ async function readOpenClawMemory(tree, _memoryConfig, extract) {
1041
1284
 
1042
1285
  // src/migrate.ts
1043
1286
  async function migrate(options) {
1287
+ const targetHome = expandHome(options.target ?? process.env.GAJAEWAY_HOME ?? `${process.env.HOME ?? "~"}/.gajaeway`);
1288
+ if (options.dryRun)
1289
+ return migrateUnlocked(options, targetHome);
1290
+ let lease;
1291
+ try {
1292
+ lease = await acquireLease(targetHome, "memory");
1293
+ } catch (error) {
1294
+ if (error instanceof LeaseHeldError)
1295
+ throw new Error(`${error.message}; stop the gateway first before importing memory data`, { cause: error });
1296
+ throw error;
1297
+ }
1298
+ try {
1299
+ await migrateUnlocked(options, targetHome);
1300
+ } finally {
1301
+ await lease.release();
1302
+ }
1303
+ }
1304
+ async function migrateUnlocked(options, targetHome) {
1044
1305
  const sourceRoot = expandHome(options.source ?? ".");
1045
- const targetHome = expandHome(options.target ?? `${process.env.HOME ?? "~"}/.gajaeway`);
1046
1306
  let sourceType;
1047
1307
  if (await detectHermes(sourceRoot)) {
1048
1308
  sourceType = "hermes";
@@ -1099,22 +1359,34 @@ Migration summary:`);
1099
1359
  }
1100
1360
  return;
1101
1361
  }
1102
- await mkdir(targetHome, { recursive: true });
1362
+ await mkdir2(targetHome, { recursive: true });
1103
1363
  const channels = emptyChannelPlan();
1104
1364
  emitChannels(extract, channels, targetHome, sourceType);
1105
1365
  emitMonitors(extract);
1106
1366
  emitImportedAxis(extract);
1107
1367
  for (const file of extract.files) {
1108
- const filePath = join4(targetHome, file.target);
1109
- await mkdir(join4(filePath, ".."), { recursive: true });
1110
- await writeFile(filePath, file.content);
1368
+ const filePath = join5(targetHome, file.target);
1369
+ await mkdir2(join5(filePath, ".."), { recursive: true });
1370
+ if (file.secret) {
1371
+ const temporary = `${filePath}.${randomUUID()}.tmp`;
1372
+ try {
1373
+ await writeFile(temporary, file.content, { mode: 384 });
1374
+ await chmod(temporary, 384);
1375
+ await rename(temporary, filePath);
1376
+ } catch (error) {
1377
+ await rm(temporary, { force: true });
1378
+ throw error;
1379
+ }
1380
+ } else {
1381
+ await writeFile(filePath, file.content);
1382
+ }
1111
1383
  }
1112
1384
  for (const merge of extract.merges) {
1113
- const filePath = join4(targetHome, merge.target);
1114
- await mkdir(join4(filePath, ".."), { recursive: true });
1385
+ const filePath = join5(targetHome, merge.target);
1386
+ await mkdir2(join5(filePath, ".."), { recursive: true });
1115
1387
  let existing;
1116
1388
  try {
1117
- const text = await readFile2(filePath, "utf-8");
1389
+ const text = await readFile3(filePath, "utf-8");
1118
1390
  existing = JSON.parse(text);
1119
1391
  } catch {}
1120
1392
  const merged = merge.merge(existing);
@@ -1145,21 +1417,29 @@ function parseMigrateArgs(args) {
1145
1417
  }
1146
1418
 
1147
1419
  // src/restart-stack.ts
1148
- import { randomUUID } from "crypto";
1149
- import { access, mkdir as mkdir3, readFile as readFile4, rename, stat, writeFile as writeFile3 } from "fs/promises";
1420
+ import { randomUUID as randomUUID2 } from "crypto";
1421
+ import { access, mkdir as mkdir4, readFile as readFile5, rename as rename2, stat, writeFile as writeFile3 } from "fs/promises";
1150
1422
  import { homedir as homedir3 } from "os";
1151
- import { join as join6 } from "path";
1423
+ import { dirname, join as join7 } from "path";
1152
1424
 
1153
1425
  // src/services.ts
1154
- import { chmod, mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
1426
+ import { chmod as chmod2, mkdir as mkdir3, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
1155
1427
  import { homedir as homedir2 } from "os";
1156
- import { join as join5 } from "path";
1428
+ import { join as join6 } from "path";
1157
1429
  import { parseRuntimeConfig, RuntimeConfigError } from "@gajae-gateway/protocol";
1158
1430
  var CONFIG_SCHEMA_VERSION = 1;
1159
1431
  var FALLBACK_PATH_DIRS = ["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
1160
1432
  var LOGIN_PATH_COMMAND = 'printf %s "$PATH"';
1433
+ var BROKER_UNIT = "gajaeway-broker.service";
1161
1434
  var GATEWAY_UNIT = "gajaeway-gateway.service";
1162
1435
  var SERVICE_SPECS = [
1436
+ {
1437
+ id: "broker",
1438
+ label: "dev.gajaeway.broker",
1439
+ binary: "gajaeway-gateway",
1440
+ args: ["broker"],
1441
+ dependsOnGateway: false
1442
+ },
1163
1443
  {
1164
1444
  id: "gateway",
1165
1445
  label: "dev.gajaeway.gateway",
@@ -1181,16 +1461,23 @@ var SERVICE_SPECS = [
1181
1461
  args: [],
1182
1462
  dependsOnGateway: true
1183
1463
  },
1464
+ {
1465
+ id: "adapter-telegram",
1466
+ label: "dev.gajaeway.adapter-telegram",
1467
+ binary: "gajaeway-telegram",
1468
+ args: [],
1469
+ dependsOnGateway: true
1470
+ },
1184
1471
  { id: "admin", label: "dev.gajaeway.admin", binary: "gajaeway-admin", args: ["serve"], dependsOnGateway: true }
1185
1472
  ];
1186
1473
  function serviceSpecs() {
1187
1474
  return SERVICE_SPECS;
1188
1475
  }
1189
1476
  function homeForEnvironment(env) {
1190
- return env.GAJAEWAY_HOME || join5(env.HOME || homedir2(), ".gajaeway");
1477
+ return env.GAJAEWAY_HOME || join6(env.HOME || homedir2(), ".gajaeway");
1191
1478
  }
1192
1479
  function expandHome2(value, home) {
1193
- return value.startsWith("~/") ? join5(home, value.slice(2)) : value;
1480
+ return value.startsWith("~/") ? join6(home, value.slice(2)) : value;
1194
1481
  }
1195
1482
  function normalizedEntries(entries2, home) {
1196
1483
  const seen = new Set;
@@ -1209,9 +1496,9 @@ function normalizedEntries(entries2, home) {
1209
1496
  function mergePathEntries(binDir, userHome, loginPath) {
1210
1497
  return normalizedEntries([
1211
1498
  expandHome2(binDir, userHome),
1212
- join5(userHome, "bin"),
1213
- join5(userHome, ".local", "bin"),
1214
- join5(userHome, ".bun", "bin"),
1499
+ join6(userHome, "bin"),
1500
+ join6(userHome, ".local", "bin"),
1501
+ join6(userHome, ".bun", "bin"),
1215
1502
  ...loginPath.replace(/(?:\r\n|\n)+$/, "").split(":"),
1216
1503
  ...FALLBACK_PATH_DIRS
1217
1504
  ], userHome);
@@ -1254,7 +1541,7 @@ function plistString(value) {
1254
1541
  return `<string>${escapeXml(value)}</string>`;
1255
1542
  }
1256
1543
  function renderLaunchAgent(spec, binDir, home, path) {
1257
- const program = join5(binDir, spec.binary);
1544
+ const program = join6(binDir, spec.binary);
1258
1545
  const argumentsXml = [program, ...spec.args].map(plistString).join("");
1259
1546
  return `<?xml version="1.0" encoding="UTF-8"?>
1260
1547
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -1268,8 +1555,8 @@ function renderLaunchAgent(spec, binDir, home, path) {
1268
1555
  </dict>
1269
1556
  <key>RunAtLoad</key><true/>
1270
1557
  <key>KeepAlive</key><true/>
1271
- <key>StandardOutPath</key>${plistString(join5(home, `${spec.id}.stdout.log`))}
1272
- <key>StandardErrorPath</key>${plistString(join5(home, `${spec.id}.stderr.log`))}
1558
+ <key>StandardOutPath</key>${plistString(join6(home, `${spec.id}.stdout.log`))}
1559
+ <key>StandardErrorPath</key>${plistString(join6(home, `${spec.id}.stderr.log`))}
1273
1560
  </dict></plist>
1274
1561
  `;
1275
1562
  }
@@ -1279,21 +1566,30 @@ function unitEnvironment(name, value) {
1279
1566
  function systemdUnitName(spec) {
1280
1567
  return `gajaeway-${spec.id}.service`;
1281
1568
  }
1569
+ function unitPath(path) {
1570
+ if (!path.startsWith("/") || /[\n\r\0]/.test(path))
1571
+ throw new Error(`unit path must be absolute and single-line: ${path}`);
1572
+ return path.replaceAll("%", "%%");
1573
+ }
1574
+ function unitEnvironmentFile(path) {
1575
+ return `EnvironmentFile=-${unitPath(path)}`;
1576
+ }
1282
1577
  function renderSystemdUnit(spec, binDir, home, path) {
1283
- const execStart = [join5(binDir, spec.binary), ...spec.args].join(" ");
1578
+ const execStart = [join6(binDir, spec.binary), ...spec.args].join(" ");
1284
1579
  const unit = [
1285
1580
  "[Unit]",
1286
1581
  `Description=gajaeway ${spec.id}`,
1287
- ...spec.dependsOnGateway ? [`BindsTo=${GATEWAY_UNIT}`, `After=${GATEWAY_UNIT}`, `PartOf=${GATEWAY_UNIT}`] : [],
1582
+ ...spec.id === "broker" ? [`ConditionPathExists=${unitPath(homeLayout(home).brokerProvisionManifest)}`] : spec.id === "gateway" ? [`Wants=${BROKER_UNIT}`, `After=${BROKER_UNIT}`] : spec.dependsOnGateway ? [`BindsTo=${GATEWAY_UNIT}`, `After=${GATEWAY_UNIT}`, `PartOf=${GATEWAY_UNIT}`] : [],
1288
1583
  "",
1289
1584
  "[Service]",
1290
1585
  `ExecStart=${execStart}`,
1291
1586
  `WorkingDirectory=${binDir}`,
1292
1587
  unitEnvironment("GAJAEWAY_HOME", home),
1293
1588
  unitEnvironment("PATH", path),
1589
+ ...spec.id === "broker" ? [unitEnvironmentFile(join6(home, "broker.env"))] : [],
1294
1590
  "Restart=always",
1295
- "RestartSec=2",
1296
- ...spec.dependsOnGateway ? [] : ["KillMode=mixed", "TimeoutStopSec=30s"],
1591
+ spec.id === "broker" ? "RestartSec=2s" : "RestartSec=2",
1592
+ ...spec.id === "broker" ? ["KillMode=control-group", "TimeoutStopSec=30s", "SuccessExitStatus=143"] : spec.id === "gateway" ? ["KillMode=mixed", "TimeoutStopSec=30s"] : [],
1297
1593
  "",
1298
1594
  "[Install]",
1299
1595
  `WantedBy=${spec.dependsOnGateway ? GATEWAY_UNIT : "default.target"}`,
@@ -1303,18 +1599,21 @@ function renderSystemdUnit(spec, binDir, home, path) {
1303
1599
  `);
1304
1600
  }
1305
1601
  function systemdUnitDir(env, userHome) {
1306
- const base = env.XDG_CONFIG_HOME || join5(userHome, ".config");
1307
- return join5(base, "systemd", "user");
1602
+ const base = env.XDG_CONFIG_HOME || join6(userHome, ".config");
1603
+ return join6(base, "systemd", "user");
1308
1604
  }
1309
1605
  function launchAgentPlistPath(spec, launchAgentsDir) {
1310
- return join5(launchAgentsDir, `dev.gajaeway.${spec.id}.plist`);
1606
+ return join6(launchAgentsDir, `dev.gajaeway.${spec.id}.plist`);
1311
1607
  }
1312
1608
  function systemdUnitPath(spec, unitDir) {
1313
- return join5(unitDir, systemdUnitName(spec));
1609
+ return join6(unitDir, systemdUnitName(spec));
1314
1610
  }
1315
1611
  function restartStackCommands(platform, uid) {
1316
1612
  if (platform === "linux")
1317
- return [["systemctl", "--user", "restart", GATEWAY_UNIT]];
1613
+ return [
1614
+ ["systemctl", "--user", "restart", BROKER_UNIT],
1615
+ ["systemctl", "--user", "restart", GATEWAY_UNIT]
1616
+ ];
1318
1617
  if (platform === "darwin") {
1319
1618
  const target = uid ?? process.getuid?.() ?? 0;
1320
1619
  return restartOrder().map((spec) => ["launchctl", "kickstart", "-k", `gui/${target}/${spec.label}`]);
@@ -1338,10 +1637,10 @@ function currentPlatform() {
1338
1637
  throw new Error(`unsupported service platform: ${process.platform}`);
1339
1638
  }
1340
1639
  async function readRuntime(home) {
1341
- const configPath = join5(home, "config.json");
1640
+ const configPath = join6(home, "config.json");
1342
1641
  let raw;
1343
1642
  try {
1344
- raw = await readFile3(configPath, "utf8");
1643
+ raw = await readFile4(configPath, "utf8");
1345
1644
  } catch (error) {
1346
1645
  throw new Error(`could not read ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
1347
1646
  }
@@ -1371,7 +1670,7 @@ async function installServices(options) {
1371
1670
  const userHome = env.HOME || homedir2();
1372
1671
  const home = homeForEnvironment(env);
1373
1672
  const binDir = expandHome2(options.binDir, userHome);
1374
- const targetDir = platform === "darwin" ? expandHome2(options.launchAgentsDir ?? join5(userHome, "Library", "LaunchAgents"), userHome) : expandHome2(options.unitDir ?? systemdUnitDir(env, userHome), userHome);
1673
+ const targetDir = platform === "darwin" ? expandHome2(options.launchAgentsDir ?? join6(userHome, "Library", "LaunchAgents"), userHome) : expandHome2(options.unitDir ?? systemdUnitDir(env, userHome), userHome);
1375
1674
  const runtime = await readRuntime(home);
1376
1675
  const path = await resolvePath({
1377
1676
  binDir,
@@ -1380,18 +1679,36 @@ async function installServices(options) {
1380
1679
  env,
1381
1680
  loginPathRunner: options.loginPathRunner
1382
1681
  });
1383
- await mkdir2(targetDir, { recursive: true, mode: 448 });
1384
- const definitions = SERVICE_SPECS.map((spec) => platform === "darwin" ? {
1385
- path: join5(targetDir, `dev.gajaeway.${spec.id}.plist`),
1682
+ await mkdir3(targetDir, { recursive: true, mode: 448 });
1683
+ const specs = [];
1684
+ for (const spec of SERVICE_SPECS) {
1685
+ if (options.configuredAdaptersOnly && spec.id.startsWith("adapter-")) {
1686
+ const adapter = spec.id.slice("adapter-".length);
1687
+ if (!await Bun.file(join6(home, `adapter-${adapter}.json`)).exists())
1688
+ continue;
1689
+ }
1690
+ specs.push(spec);
1691
+ }
1692
+ const definitions = specs.map((spec) => platform === "darwin" ? {
1693
+ path: join6(targetDir, `dev.gajaeway.${spec.id}.plist`),
1386
1694
  contents: renderLaunchAgent(spec, binDir, home, path)
1387
- } : { path: join5(targetDir, systemdUnitName(spec)), contents: renderSystemdUnit(spec, binDir, home, path) });
1695
+ } : { path: join6(targetDir, systemdUnitName(spec)), contents: renderSystemdUnit(spec, binDir, home, path) });
1388
1696
  const write = options.writeFile;
1389
1697
  for (const definition of definitions) {
1698
+ if (options.compareAndWrite) {
1699
+ try {
1700
+ if (await readFile4(definition.path, "utf8") === definition.contents)
1701
+ continue;
1702
+ } catch (error) {
1703
+ if (error.code !== "ENOENT")
1704
+ throw error;
1705
+ }
1706
+ }
1390
1707
  if (write)
1391
1708
  await write(definition.path, definition.contents);
1392
1709
  else {
1393
1710
  await writeFile2(definition.path, definition.contents, { encoding: "utf8", mode: 384 });
1394
- await chmod(definition.path, 384);
1711
+ await chmod2(definition.path, 384);
1395
1712
  }
1396
1713
  }
1397
1714
  return definitions.map((definition) => definition.path);
@@ -1405,12 +1722,12 @@ var RECEIPT_FILE = "restart-stack.json";
1405
1722
  var VERIFY_TIMEOUT_MS = 90000;
1406
1723
  var POLL_MS = 1000;
1407
1724
  function restartReceiptPath(home) {
1408
- return join6(home, RECEIPT_FILE);
1725
+ return join7(home, RECEIPT_FILE);
1409
1726
  }
1410
1727
  async function readRestartReceipt(home) {
1411
1728
  let raw;
1412
1729
  try {
1413
- raw = await readFile4(restartReceiptPath(home), "utf8");
1730
+ raw = await readFile5(restartReceiptPath(home), "utf8");
1414
1731
  } catch (error) {
1415
1732
  if (error.code === "ENOENT")
1416
1733
  return;
@@ -1419,23 +1736,24 @@ async function readRestartReceipt(home) {
1419
1736
  return JSON.parse(raw);
1420
1737
  }
1421
1738
  async function writeReceipt(home, receipt) {
1422
- await mkdir3(home, { recursive: true });
1739
+ await mkdir4(home, { recursive: true });
1423
1740
  const path = restartReceiptPath(home);
1424
1741
  const temporary = `${path}.${process.pid}.tmp`;
1425
1742
  await writeFile3(temporary, `${JSON.stringify(receipt, null, 2)}
1426
1743
  `, { mode: 384 });
1427
- await rename(temporary, path);
1744
+ await rename2(temporary, path);
1428
1745
  }
1429
- function newReceipt(id, platform, uid, now) {
1746
+ function newReceipt(id, platform, uid, now, trigger) {
1430
1747
  const commands = restartStackCommands(platform, uid);
1431
1748
  return {
1432
1749
  id,
1750
+ trigger,
1433
1751
  state: "queued",
1434
1752
  platform,
1435
1753
  requestedAt: new Date(now).toISOString(),
1436
1754
  steps: restartOrder().map((spec, index) => ({
1437
1755
  label: spec.label,
1438
- ...platform === "darwin" ? { command: commands[index] } : index === 0 ? { command: commands[0] } : {},
1756
+ ...platform === "darwin" ? { command: commands[index] } : spec.id === "broker" ? { command: commands[0] } : spec.id === "gateway" ? { command: commands[1] } : {},
1439
1757
  result: "pending"
1440
1758
  }))
1441
1759
  };
@@ -1454,33 +1772,17 @@ async function capture(command) {
1454
1772
  const output = await new Response(child.stdout).text();
1455
1773
  return await child.exited === 0 ? output : undefined;
1456
1774
  }
1457
- function defaultServiceProbe(platform) {
1775
+ function defaultServiceProbe(platform, uid = process.getuid?.() ?? 0) {
1458
1776
  return async (label) => {
1459
1777
  const spec = serviceSpecs().find((candidate) => candidate.label === label);
1460
1778
  if (spec === undefined)
1461
1779
  throw new Error(`unknown service label: ${label}`);
1462
- let pid;
1463
- if (platform === "darwin") {
1464
- const listed = await capture(["launchctl", "list", label]);
1465
- const match = listed?.match(/"PID"\s*=\s*(\d+);/);
1466
- pid = match ? Number(match[1]) : undefined;
1467
- } else {
1468
- const shown = await capture(["systemctl", "--user", "show", "-p", "MainPID", "--value", systemdUnitName(spec)]);
1469
- pid = shown === undefined ? undefined : Number(shown.trim());
1470
- }
1471
- if (pid === undefined || !Number.isInteger(pid) || pid <= 0)
1780
+ const shown = platform === "darwin" ? await capture(["launchctl", "print", `gui/${uid}/${label}`]) : await capture(["systemctl", "--user", "show", "-p", "MainPID", "--value", systemdUnitName(spec)]);
1781
+ const rawPid = platform === "darwin" ? shown?.match(/^\s*pid = (\d+);?\s*$/m)?.[1] : shown?.trim();
1782
+ const pid = rawPid === undefined ? undefined : Number(rawPid);
1783
+ if (pid === undefined || !Number.isSafeInteger(pid) || pid <= 0)
1472
1784
  return;
1473
- const lstart = await capture(["ps", "-o", "lstart=", "-p", String(pid)]);
1474
- const args = await capture(["ps", "-ww", "-o", "args=", "-p", String(pid)]);
1475
- if (lstart === undefined || args === undefined)
1476
- return;
1477
- const startedAt = Date.parse(lstart.trim());
1478
- if (!Number.isFinite(startedAt))
1479
- return;
1480
- const suffix = spec.args.length > 0 ? ` ${spec.args.join(" ")}` : "";
1481
- const line = args.trim();
1482
- const binary = suffix && line.endsWith(suffix) ? line.slice(0, -suffix.length) : line;
1483
- return { pid, startedAt, binary };
1785
+ return { pid };
1484
1786
  };
1485
1787
  }
1486
1788
  async function defaultBinaryModifiedAt(path) {
@@ -1493,7 +1795,7 @@ function defaultIsServiceInstalled(options) {
1493
1795
  return false;
1494
1796
  if (options.platform === "darwin") {
1495
1797
  const userHome2 = options.env.HOME || homedir3();
1496
- const agentsDir = options.launchAgentsDir || join6(userHome2, "Library", "LaunchAgents");
1798
+ const agentsDir = options.launchAgentsDir || join7(userHome2, "Library", "LaunchAgents");
1497
1799
  const plistPath = launchAgentPlistPath(spec, agentsDir);
1498
1800
  try {
1499
1801
  await access(plistPath);
@@ -1504,23 +1806,61 @@ function defaultIsServiceInstalled(options) {
1504
1806
  }
1505
1807
  const userHome = options.env.HOME || homedir3();
1506
1808
  const unitDirPath = options.unitDir || systemdUnitDir(options.env, userHome);
1507
- const unitPath = systemdUnitPath(spec, unitDirPath);
1809
+ const unitPath2 = systemdUnitPath(spec, unitDirPath);
1508
1810
  try {
1509
- await access(unitPath);
1811
+ await access(unitPath2);
1510
1812
  return true;
1511
1813
  } catch {
1512
1814
  return false;
1513
1815
  }
1514
1816
  };
1515
1817
  }
1516
- function psStartBound(ms) {
1517
- return Math.floor(ms / 1000) * 1000 - PS_START_SKEW_MS;
1818
+ var SERVICE_LEASES = {
1819
+ broker: "broker",
1820
+ gateway: "gateway",
1821
+ "adapter-discord": "adapter-discord",
1822
+ "adapter-slack": "adapter-slack",
1823
+ "adapter-telegram": "adapter-telegram"
1824
+ };
1825
+ function serviceStopCommand(platform, uid) {
1826
+ if (platform === "linux")
1827
+ return ["systemctl", "--user", "stop", BROKER_UNIT];
1828
+ const target = uid ?? process.getuid?.() ?? 0;
1829
+ return ["launchctl", "bootout", `gui/${target}/dev.gajaeway.broker`];
1830
+ }
1831
+ function serviceBootstrapCommand(options, platform, label) {
1832
+ if (platform === "linux")
1833
+ return ["systemctl", "--user", "start", BROKER_UNIT];
1834
+ const target = options.uid ?? process.getuid?.() ?? 0;
1835
+ const userHome = options.env?.HOME || homedir3();
1836
+ const agentsDir = options.launchAgentsDir ?? join7(userHome, "Library", "LaunchAgents");
1837
+ const spec = serviceSpecs().find((candidate) => candidate.label === label);
1838
+ if (spec === undefined)
1839
+ throw new Error(`unknown service label: ${label}`);
1840
+ return ["launchctl", "bootstrap", `gui/${target}`, launchAgentPlistPath(spec, agentsDir)];
1518
1841
  }
1519
- var PS_START_SKEW_MS = 1000;
1520
1842
  async function runRestartStack(options) {
1843
+ let lease;
1844
+ try {
1845
+ lease = await acquireLease(options.home, "update");
1846
+ console.info(`lease_acquired name=update pid=${process.pid}`);
1847
+ } catch (error) {
1848
+ if (error instanceof LeaseHeldError)
1849
+ console.error(error.message);
1850
+ else
1851
+ console.error(`lease_failed name=update detail=${error instanceof Error ? error.message : String(error)}`);
1852
+ throw error;
1853
+ }
1854
+ try {
1855
+ return await runRestartStackWithLease(options);
1856
+ } finally {
1857
+ await lease.release();
1858
+ }
1859
+ }
1860
+ async function runRestartStackWithLease(options) {
1521
1861
  const platform = options.platform ?? currentPlatform();
1522
1862
  const runner = options.runner ?? defaultCommandRunner;
1523
- const probe = options.probe ?? defaultServiceProbe(platform);
1863
+ const probe = options.probe ?? defaultServiceProbe(platform, options.uid);
1524
1864
  const modifiedAt = options.binaryModifiedAt ?? defaultBinaryModifiedAt;
1525
1865
  const isInstalled = options.isServiceInstalled ?? defaultIsServiceInstalled({
1526
1866
  platform,
@@ -1532,8 +1872,14 @@ async function runRestartStack(options) {
1532
1872
  const sleep = options.sleep ?? ((ms) => Bun.sleep(ms));
1533
1873
  const timeoutMs = options.verifyTimeoutMs ?? VERIFY_TIMEOUT_MS;
1534
1874
  const pollMs = options.pollMs ?? POLL_MS;
1875
+ const binDir = options.binDir ?? process.env.GAJAEWAY_BIN_DIR ?? dirname(process.execPath);
1876
+ const originalPids = new Map;
1877
+ for (const spec of restartOrder()) {
1878
+ if (SERVICE_LEASES[spec.id] === undefined && await isInstalled(spec.label))
1879
+ originalPids.set(spec.label, (await probe(spec.label))?.pid);
1880
+ }
1535
1881
  const existing = await readRestartReceipt(options.home);
1536
- const receipt = existing?.id === options.id && existing.state === "queued" ? existing : newReceipt(options.id, platform, options.uid, now());
1882
+ const receipt = existing?.id === options.id && existing.state === "queued" ? existing : newReceipt(options.id, platform, options.uid, now(), options.trigger ?? "manual");
1537
1883
  receipt.state = "running";
1538
1884
  receipt.supervisorPid = process.pid;
1539
1885
  await writeReceipt(options.home, receipt);
@@ -1544,6 +1890,12 @@ async function runRestartStack(options) {
1544
1890
  continue;
1545
1891
  }
1546
1892
  try {
1893
+ if (step.label === "dev.gajaeway.broker" && !await Bun.file(homeLayout(options.home).brokerProvisionManifest).exists()) {
1894
+ step.result = "skipped";
1895
+ step.detail = "broker agent is not provisioned";
1896
+ await writeReceipt(options.home, receipt);
1897
+ continue;
1898
+ }
1547
1899
  const installed = await isInstalled(step.label);
1548
1900
  if (!installed) {
1549
1901
  step.result = "skipped";
@@ -1551,17 +1903,68 @@ async function runRestartStack(options) {
1551
1903
  await writeReceipt(options.home, receipt);
1552
1904
  continue;
1553
1905
  }
1554
- if (step.command !== undefined) {
1555
- issuedAt = now();
1556
- const status = await runner(step.command);
1557
- step.exitStatus = status;
1558
- if (status !== 0) {
1906
+ if (step.label === "dev.gajaeway.broker" && receipt.trigger === "update") {
1907
+ const stopCommand = serviceStopCommand(platform, options.uid);
1908
+ const stopStatus = await runner(stopCommand);
1909
+ if (stopStatus !== 0 && !(platform === "darwin" && await probe(step.label) === undefined))
1910
+ throw new Error(`${stopCommand.join(" ")} exited with status ${stopStatus}`);
1911
+ const provisionCommand = [
1912
+ join7(binDir, "gajaeway-gateway"),
1913
+ "broker-agent",
1914
+ "provision",
1915
+ "--mode",
1916
+ "update",
1917
+ "--lease-wait-ms",
1918
+ "35000"
1919
+ ];
1920
+ const provisionStatus = await runner(provisionCommand);
1921
+ step.agentSync = {
1922
+ command: provisionCommand,
1923
+ exitStatus: provisionStatus,
1924
+ ...provisionStatus === 1 || provisionStatus === 3 ? { error: `broker-agent provision exited with status ${provisionStatus}` } : provisionStatus === 4 ? { error: "broker_agent_unrecoverable" } : {}
1925
+ };
1926
+ if (provisionStatus === 4) {
1927
+ delete step.command;
1928
+ step.exitStatus = 4;
1559
1929
  step.result = "failed";
1560
- step.detail = `${step.command.join(" ")} exited with status ${status}`;
1930
+ step.detail = "broker left stopped: broker_agent_unrecoverable; run gajaeway setup --broker-agent";
1931
+ } else if (provisionStatus === 0 || provisionStatus === 1 || provisionStatus === 3) {
1932
+ const startCommand = serviceBootstrapCommand(options, platform, step.label);
1933
+ step.command = startCommand;
1934
+ issuedAt = now();
1935
+ const startStatus = await runner(startCommand);
1936
+ step.exitStatus = startStatus;
1937
+ if (startStatus !== 0) {
1938
+ step.result = "failed";
1939
+ step.detail = `${startCommand.join(" ")} exited with status ${startStatus}`;
1940
+ } else
1941
+ await verifyStep(step, issuedAt);
1942
+ } else {
1943
+ delete step.command;
1944
+ step.exitStatus = provisionStatus;
1945
+ step.result = "failed";
1946
+ step.detail = `broker-agent provision exited with unexpected status ${provisionStatus}`;
1947
+ }
1948
+ } else {
1949
+ if (step.command !== undefined) {
1950
+ issuedAt = now();
1951
+ let command = step.command;
1952
+ let status = await runner(command);
1953
+ if (status !== 0 && receipt.trigger === "update" && platform === "darwin" && await probe(step.label) === undefined) {
1954
+ command = serviceBootstrapCommand(options, platform, step.label);
1955
+ step.command = command;
1956
+ issuedAt = now();
1957
+ status = await runner(command);
1958
+ }
1959
+ step.exitStatus = status;
1960
+ if (status !== 0) {
1961
+ step.result = "failed";
1962
+ step.detail = `${command.join(" ")} exited with status ${status}`;
1963
+ }
1561
1964
  }
1965
+ if (step.result === "pending")
1966
+ await verifyStep(step, issuedAt);
1562
1967
  }
1563
- if (step.result === "pending")
1564
- await verifyStep(step, issuedAt);
1565
1968
  } catch (error) {
1566
1969
  step.result = "failed";
1567
1970
  step.detail = error instanceof Error ? error.message : String(error);
@@ -1577,29 +1980,53 @@ async function runRestartStack(options) {
1577
1980
  await writeReceipt(options.home, receipt);
1578
1981
  return receipt;
1579
1982
  async function verifyStep(step, since) {
1983
+ const spec = serviceSpecs().find((candidate) => candidate.label === step.label);
1984
+ if (spec === undefined)
1985
+ throw new Error(`unknown service label: ${step.label}`);
1986
+ const leaseName = SERVICE_LEASES[spec.id];
1987
+ const binary = join7(binDir, spec.binary);
1988
+ const binaryModifiedAt = leaseName === undefined ? undefined : await modifiedAt(binary);
1580
1989
  const deadline = now() + timeoutMs;
1581
1990
  for (;; ) {
1582
1991
  const running = await probe(step.label);
1583
- let binaryModifiedAt = 0;
1584
- if (running !== undefined) {
1585
- binaryModifiedAt = await modifiedAt(running.binary);
1992
+ const owner = leaseName === undefined ? undefined : await readLeaseOwner(options.home, leaseName);
1993
+ if (running !== undefined)
1586
1994
  step.pid = running.pid;
1587
- step.processStartedAt = new Date(running.startedAt).toISOString();
1588
- step.binary = running.binary;
1995
+ if (leaseName !== undefined) {
1996
+ step.binary = binary;
1589
1997
  step.binaryModifiedAt = new Date(binaryModifiedAt).toISOString();
1590
- if (running.startedAt >= psStartBound(Math.max(binaryModifiedAt, since))) {
1998
+ if (owner !== undefined)
1999
+ step.processStartedAt = owner.startedAt;
2000
+ const startedAt = owner === undefined ? Number.NaN : Date.parse(owner.startedAt);
2001
+ if (running !== undefined && owner !== undefined && running.pid === owner.pid && Number.isFinite(startedAt) && startedAt >= Math.max(since, binaryModifiedAt)) {
1591
2002
  step.result = "ok";
1592
2003
  delete step.detail;
1593
2004
  return;
1594
2005
  }
2006
+ } else if (running !== undefined && running.pid !== originalPids.get(step.label)) {
2007
+ step.result = "ok";
2008
+ delete step.detail;
2009
+ return;
1595
2010
  }
1596
2011
  if (now() >= deadline) {
1597
2012
  if (running === undefined) {
1598
2013
  step.result = "failed";
1599
2014
  step.detail = "no running process after restart";
2015
+ } else if (leaseName === undefined) {
2016
+ step.result = "stale";
2017
+ step.detail = "MainPID did not change after restart";
2018
+ } else if (owner === undefined) {
2019
+ step.result = "stale";
2020
+ step.detail = "no lease owner after restart";
2021
+ } else if (running.pid !== owner.pid) {
2022
+ step.result = "stale";
2023
+ step.detail = "lease owner PID does not match service MainPID";
2024
+ } else if (Date.parse(owner.startedAt) < binaryModifiedAt) {
2025
+ step.result = "stale";
2026
+ step.detail = "process started before the deployed binary was modified";
1600
2027
  } else {
1601
2028
  step.result = "stale";
1602
- step.detail = running.startedAt < binaryModifiedAt ? "process started before the deployed binary was modified" : "process was not restarted by this sequence";
2029
+ step.detail = "lease owner predates this restart sequence";
1603
2030
  }
1604
2031
  return;
1605
2032
  }
@@ -1610,35 +2037,40 @@ async function runRestartStack(options) {
1610
2037
  function cliCommand() {
1611
2038
  return Bun.main.startsWith("/$bunfs/") ? [process.execPath] : [process.execPath, Bun.main];
1612
2039
  }
1613
- function alive(pid) {
1614
- if (pid === undefined)
1615
- return false;
1616
- try {
1617
- process.kill(pid, 0);
1618
- return true;
1619
- } catch {
1620
- return false;
1621
- }
1622
- }
1623
2040
  async function launchRestartStack(options) {
1624
2041
  const platform = options.platform ?? currentPlatform();
1625
- const existing = await readRestartReceipt(options.home);
1626
- if (existing?.state === "running" && alive(existing.supervisorPid))
1627
- throw new Error(`restart-stack ${existing.id} is still running (supervisor pid ${existing.supervisorPid})`);
1628
- const receipt = newReceipt(randomUUID(), platform, options.uid, Date.now());
2042
+ if (await leaseHeld(options.home, "update"))
2043
+ throw new LeaseHeldError("update", await readLeaseOwner(options.home, "update"));
2044
+ const receipt = newReceipt(randomUUID2(), platform, options.uid, Date.now(), options.trigger ?? "manual");
1629
2045
  await writeReceipt(options.home, receipt);
1630
2046
  const worker = [...options.worker ?? [...cliCommand(), "ops", "restart-stack"], "--run", receipt.id];
1631
- const child = Bun.spawn(supervisorArgv(platform, worker, options.home, receipt.id), {
1632
- detached: true,
1633
- stdin: "ignore",
1634
- stdout: "ignore",
1635
- stderr: "ignore",
1636
- env: { ...options.env ?? process.env, GAJAEWAY_HOME: options.home }
1637
- });
2047
+ const argv = supervisorArgv(platform, worker, options.home, receipt.id, options.binDir);
2048
+ let child;
2049
+ try {
2050
+ child = Bun.spawn(argv, {
2051
+ detached: true,
2052
+ stdin: "ignore",
2053
+ stdout: "ignore",
2054
+ stderr: "ignore",
2055
+ env: {
2056
+ ...options.env ?? process.env,
2057
+ GAJAEWAY_HOME: options.home,
2058
+ ...options.binDir === undefined ? {} : { GAJAEWAY_BIN_DIR: options.binDir }
2059
+ }
2060
+ });
2061
+ } catch (error) {
2062
+ receipt.state = "failed";
2063
+ receipt.finishedAt = new Date().toISOString();
2064
+ receipt.detail = `supervisor ${argv[0]} could not be started (${error instanceof Error ? error.message : String(error)}); without a service manager here, restart through the host's own supervisor`;
2065
+ for (const step of receipt.steps)
2066
+ step.result = "skipped";
2067
+ await writeReceipt(options.home, receipt);
2068
+ throw new Error(`restart-stack ${receipt.id}: failed: ${receipt.detail}`);
2069
+ }
1638
2070
  child.unref();
1639
2071
  return { receipt, supervisorPid: child.pid };
1640
2072
  }
1641
- function supervisorArgv(platform, worker, home, id) {
2073
+ function supervisorArgv(platform, worker, home, id, binDir) {
1642
2074
  if (platform === "darwin")
1643
2075
  return [...worker];
1644
2076
  return [
@@ -1648,26 +2080,29 @@ function supervisorArgv(platform, worker, home, id) {
1648
2080
  "--quiet",
1649
2081
  `--unit=gajaeway-restart-stack-${id}`,
1650
2082
  `--setenv=GAJAEWAY_HOME=${home}`,
2083
+ ...binDir === undefined ? [] : [`--setenv=GAJAEWAY_BIN_DIR=${binDir}`],
1651
2084
  "--",
1652
2085
  ...worker
1653
2086
  ];
1654
2087
  }
1655
- function effectiveRestartState(receipt) {
1656
- if (receipt.state === "running" && !alive(receipt.supervisorPid))
2088
+ async function effectiveRestartState(receipt, home) {
2089
+ if (receipt.state === "running" && !await leaseHeld(home, "update"))
1657
2090
  return "interrupted";
1658
2091
  return receipt.state;
1659
2092
  }
1660
- function renderRestartReceipt(receipt) {
2093
+ function renderRestartReceipt(receipt, state = receipt.state) {
1661
2094
  const lines = [
1662
- `restart-stack ${receipt.id}: ${effectiveRestartState(receipt)}`,
2095
+ `restart-stack ${receipt.id}: ${state}`,
1663
2096
  `requestedAt: ${receipt.requestedAt}`,
1664
- `finishedAt: ${receipt.finishedAt ?? "-"}`
2097
+ `finishedAt: ${receipt.finishedAt ?? "-"}`,
2098
+ ...receipt.detail === undefined ? [] : [`detail: ${receipt.detail}`]
1665
2099
  ];
1666
2100
  for (const step of receipt.steps) {
1667
2101
  const facts = [
1668
2102
  step.pid === undefined ? undefined : `pid=${step.pid}`,
1669
2103
  step.processStartedAt === undefined ? undefined : `started=${step.processStartedAt}`,
1670
2104
  step.binaryModifiedAt === undefined ? undefined : `binary=${step.binaryModifiedAt}`,
2105
+ step.agentSync?.error === undefined ? undefined : `agentSync.error=${step.agentSync.error}`,
1671
2106
  step.detail
1672
2107
  ].filter((fact) => fact !== undefined);
1673
2108
  lines.push(`${step.label}: ${step.result}${facts.length > 0 ? ` ${facts.join(" ")}` : ""}`);
@@ -1676,9 +2111,74 @@ function renderRestartReceipt(receipt) {
1676
2111
  }
1677
2112
 
1678
2113
  // src/setup.ts
1679
- import { randomUUID as randomUUID2 } from "crypto";
1680
- import { chmod as chmod2, mkdir as mkdir4, readFile as readFile5, rename as rename2, rm, writeFile as writeFile4 } from "fs/promises";
1681
- import { join as join7 } from "path";
2114
+ import { randomUUID as randomUUID3 } from "crypto";
2115
+ import { chmod as chmod3, mkdir as mkdir5, readFile as readFile6, realpath, rename as rename3, rm as rm2, writeFile as writeFile4 } from "fs/promises";
2116
+ import { basename, dirname as dirname2, join as join8 } from "path";
2117
+
2118
+ // src/discord-setup.ts
2119
+ var DEVELOPER_PORTAL_URL = "https://discord.com/developers/applications";
2120
+ var DISCORD_BOT_SCOPES = ["bot", "applications.commands"];
2121
+ var DISCORD_BOT_PERMISSIONS = 2048 | 16384 | 33554432 | 65536 | 131072 | 8192 | 64;
2122
+ function printDiscordSetupGuide(log) {
2123
+ log("");
2124
+ log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
2125
+ log(" Getting your Discord bot token");
2126
+ log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
2127
+ log("");
2128
+ log("1. Open the Discord Developer Portal:");
2129
+ log(` ${DEVELOPER_PORTAL_URL}`);
2130
+ log("");
2131
+ log("2. Click 'New Application' and choose a name (e.g., 'My gajae-way agent')");
2132
+ log("");
2133
+ log("3. In the left menu, click 'Bot' \u2192 'Add Bot'");
2134
+ log("");
2135
+ log("4. Under 'TOKEN', click 'Reset Token' \u2192 'Yes, do it!'");
2136
+ log("");
2137
+ log("5. Click 'Copy' to copy your bot token (it starts with 'MTI...')");
2138
+ log("");
2139
+ log("6. (Important) Scroll down to 'GATEWAY INTENTS' and enable 'MESSAGE CONTENT INTENT'");
2140
+ log(" This allows the bot to read message text, not just usernames.");
2141
+ log("");
2142
+ log("7. Click 'Save Changes'");
2143
+ log("");
2144
+ log("8. Come back here and paste your token below");
2145
+ log("");
2146
+ log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
2147
+ log("");
2148
+ }
2149
+ function generateDiscordInviteUrl(appId) {
2150
+ const scopeParam = DISCORD_BOT_SCOPES.join("+");
2151
+ const permissionsParam = String(DISCORD_BOT_PERMISSIONS);
2152
+ return `https://discord.com/oauth2/authorize?client_id=${appId}&scope=${scopeParam}&permissions=${permissionsParam}`;
2153
+ }
2154
+ function printDiscordBotReady(log, appId, botName) {
2155
+ const inviteUrl = generateDiscordInviteUrl(appId);
2156
+ log("");
2157
+ log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
2158
+ log(" \u2713 Discord bot token validated");
2159
+ log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
2160
+ log("");
2161
+ log(`Application ID: ${appId}`);
2162
+ if (botName) {
2163
+ log(`Bot name: ${botName}`);
2164
+ }
2165
+ log("");
2166
+ log("To invite your bot to your Discord server, click this link:");
2167
+ log("");
2168
+ log(` ${inviteUrl}`);
2169
+ log("");
2170
+ log("Then:");
2171
+ log(" 1. Choose the server where you want to add the bot");
2172
+ log(" 2. Click 'Authorize'");
2173
+ log(" 3. Complete the CAPTCHA");
2174
+ log("");
2175
+ log("Once authorized, send the bot a DM and start chatting!");
2176
+ log("");
2177
+ log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
2178
+ log("");
2179
+ }
2180
+
2181
+ // src/setup.ts
1682
2182
  var SETUP_PLATFORMS = ["discord", "slack", "telegram"];
1683
2183
  var SETUP_ENV = {
1684
2184
  discordToken: "GAJAEWAY_DISCORD_TOKEN",
@@ -1688,7 +2188,7 @@ var SETUP_ENV = {
1688
2188
  telegramToken: "GAJAEWAY_TELEGRAM_TOKEN",
1689
2189
  ownerIds: "GAJAEWAY_OWNER_IDS"
1690
2190
  };
1691
- var SETUP_USAGE = "usage: gajaeway setup [--from-env] [--adapters discord,slack,telegram] [--owner ID[,ID...]] [--discord-app-id ID] | setup --status";
2191
+ var SETUP_USAGE = "usage: gajaeway setup [--from-env] [--adapters discord,slack,telegram] [--owner ID[,ID...]] [--discord-app-id ID] | setup --status | setup --broker-agent [--from DIR]";
1692
2192
  var SECRET_FILES2 = {
1693
2193
  discord: "discord-token",
1694
2194
  slackBot: "slack-bot-token",
@@ -1713,6 +2213,8 @@ function redact(text, secrets) {
1713
2213
  function parseSetupArgs(args) {
1714
2214
  let fromEnv = false;
1715
2215
  let status = false;
2216
+ let brokerAgent = false;
2217
+ let from;
1716
2218
  let adapters;
1717
2219
  const owners = [];
1718
2220
  let discordAppId;
@@ -1722,7 +2224,15 @@ function parseSetupArgs(args) {
1722
2224
  fromEnv = true;
1723
2225
  else if (flag === "--status")
1724
2226
  status = true;
1725
- else if (flag === "--adapters" || flag === "--owner" || flag === "--discord-app-id") {
2227
+ else if (flag === "--broker-agent")
2228
+ brokerAgent = true;
2229
+ else if (flag === "--from") {
2230
+ const value = args[++i];
2231
+ if (value === undefined || value.trim() === "" || value.startsWith("--"))
2232
+ throw new SetupError(`${flag} expects a value
2233
+ ${SETUP_USAGE}`);
2234
+ from = value;
2235
+ } else if (flag === "--adapters" || flag === "--owner" || flag === "--discord-app-id") {
1726
2236
  const value = args[++i];
1727
2237
  if (value === undefined || value.trim() === "" || value.startsWith("--"))
1728
2238
  throw new SetupError(`${flag} expects a value
@@ -1739,15 +2249,22 @@ ${SETUP_USAGE}`);
1739
2249
  throw new SetupError(`unknown option: ${flag.split("=")[0]}
1740
2250
  ${SETUP_USAGE}`);
1741
2251
  }
1742
- if (status && (fromEnv || adapters || owners.length > 0 || discordAppId))
2252
+ if (status && (fromEnv || brokerAgent || from !== undefined || adapters || owners.length > 0 || discordAppId))
1743
2253
  throw new SetupError(`--status takes no other options
2254
+ ${SETUP_USAGE}`);
2255
+ if (brokerAgent && (fromEnv || adapters || owners.length > 0 || discordAppId))
2256
+ throw new SetupError(`--broker-agent is standalone and cannot be combined with adapter setup options
2257
+ ${SETUP_USAGE}`);
2258
+ if (from !== undefined && !brokerAgent)
2259
+ throw new SetupError(`--from requires --broker-agent
1744
2260
  ${SETUP_USAGE}`);
1745
2261
  return {
1746
- mode: status ? "status" : "setup",
2262
+ mode: status ? "status" : brokerAgent ? "broker-agent" : "setup",
1747
2263
  fromEnv,
1748
2264
  ...adapters ? { adapters } : {},
1749
2265
  owners,
1750
- ...discordAppId ? { discordAppId } : {}
2266
+ ...discordAppId ? { discordAppId } : {},
2267
+ ...from === undefined ? {} : { from }
1751
2268
  };
1752
2269
  }
1753
2270
  function splitList(value) {
@@ -1845,7 +2362,8 @@ async function validateDiscord(credentials, fetcher) {
1845
2362
  const ownerId = !hasTeam && typeof owner?.id === "string" ? owner.id : undefined;
1846
2363
  return {
1847
2364
  platform: "discord",
1848
- summary: `bot ${String(me.body.username ?? "?")} (${String(me.body.id ?? "?")}) of application ${appId}`,
2365
+ summary: `bot named ${String(me.body.username ?? "?")} (${String(me.body.id ?? "?")}) of application ${appId}`,
2366
+ appId,
1849
2367
  ...ownerId ? { ownerId } : {}
1850
2368
  };
1851
2369
  }
@@ -1899,19 +2417,19 @@ async function validateAll(tokens, fetcher) {
1899
2417
  return identities;
1900
2418
  }
1901
2419
  async function writePrivateFile(path, contents) {
1902
- const temporary = `${path}.${randomUUID2()}.tmp`;
2420
+ const temporary = `${path}.${randomUUID3()}.tmp`;
1903
2421
  try {
1904
2422
  await writeFile4(temporary, contents, { flag: "wx", mode: 384 });
1905
- await chmod2(temporary, 384);
1906
- await rename2(temporary, path);
2423
+ await chmod3(temporary, 384);
2424
+ await rename3(temporary, path);
1907
2425
  } finally {
1908
- await rm(temporary, { force: true });
2426
+ await rm2(temporary, { force: true });
1909
2427
  }
1910
2428
  }
1911
2429
  async function readJsonObject(path) {
1912
2430
  let text;
1913
2431
  try {
1914
- text = await readFile5(path, "utf8");
2432
+ text = await readFile6(path, "utf8");
1915
2433
  } catch (error) {
1916
2434
  if (error.code === "ENOENT")
1917
2435
  return;
@@ -1929,21 +2447,21 @@ async function readJsonObject(path) {
1929
2447
  }
1930
2448
  async function applySetup(options) {
1931
2449
  const { home, tokens } = options;
1932
- const secretsDir = join7(home, "secrets");
1933
- const configPath = join7(home, "config.json");
1934
- const adapterPath = (platform) => join7(home, `adapter-${platform}.json`);
2450
+ const secretsDir = join8(home, "secrets");
2451
+ const configPath = join8(home, "config.json");
2452
+ const adapterPath = (platform) => join8(home, `adapter-${platform}.json`);
1935
2453
  const existingConfig = await readJsonObject(configPath);
1936
2454
  const existingAdapters = new Map;
1937
2455
  for (const platform of SETUP_PLATFORMS)
1938
2456
  if (tokens[platform])
1939
2457
  existingAdapters.set(platform, await readJsonObject(adapterPath(platform)));
1940
- await mkdir4(home, { recursive: true, mode: 448 });
1941
- await chmod2(home, 448);
1942
- await mkdir4(secretsDir, { recursive: true, mode: 448 });
1943
- await chmod2(secretsDir, 448);
2458
+ await mkdir5(home, { recursive: true, mode: 448 });
2459
+ await chmod3(home, 448);
2460
+ await mkdir5(secretsDir, { recursive: true, mode: 448 });
2461
+ await chmod3(secretsDir, 448);
1944
2462
  const written = [];
1945
2463
  const secret = async (name, value) => {
1946
- const path = join7(secretsDir, name);
2464
+ const path = join8(secretsDir, name);
1947
2465
  await writePrivateFile(path, `${value}
1948
2466
  `);
1949
2467
  written.push(path);
@@ -1990,7 +2508,7 @@ async function applySetup(options) {
1990
2508
  async function enabledAdapters(home) {
1991
2509
  const enabled = [];
1992
2510
  for (const platform of SETUP_PLATFORMS) {
1993
- if (await Bun.file(join7(home, `adapter-${platform}.json`)).exists())
2511
+ if (await Bun.file(join8(home, `adapter-${platform}.json`)).exists())
1994
2512
  enabled.push(platform);
1995
2513
  }
1996
2514
  return enabled;
@@ -2011,12 +2529,14 @@ async function askRequiredSecret(prompter, question) {
2011
2529
  return answer;
2012
2530
  }
2013
2531
  }
2014
- async function tokensFromPrompts(prompter, parsed) {
2532
+ async function tokensFromPrompts(prompter, parsed, log) {
2015
2533
  const wanted = parsed.adapters ?? [];
2016
2534
  const enable = async (platform, label) => parsed.adapters ? wanted.includes(platform) : await askYes(prompter, `Enable ${label}?`);
2017
2535
  const tokens = {};
2018
2536
  if (await enable("discord", "Discord")) {
2019
- const token = await askRequiredSecret(prompter, "Discord bot token (input hidden): ");
2537
+ const logger = log ?? (() => {});
2538
+ printDiscordSetupGuide(logger);
2539
+ const token = await askRequiredSecret(prompter, "Paste your Discord bot token here (input hidden): ");
2020
2540
  const appId = parsed.discordAppId ?? (await prompter.ask("Discord application id (optional): ")).trim();
2021
2541
  tokens.discord = { token, ...appId ? { appId } : {} };
2022
2542
  }
@@ -2085,6 +2605,28 @@ function terminalPrompter() {
2085
2605
  close: () => stdin.pause()
2086
2606
  };
2087
2607
  }
2608
+ async function defaultBrokerAgentRunner(command) {
2609
+ const child = Bun.spawn([...command], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
2610
+ const [stdout, stderr, exitCode] = await Promise.all([
2611
+ new Response(child.stdout).text(),
2612
+ new Response(child.stderr).text(),
2613
+ child.exited
2614
+ ]);
2615
+ return { exitCode, stdout, stderr };
2616
+ }
2617
+ async function setupBinDir(options) {
2618
+ if (options.binDir !== undefined)
2619
+ return options.binDir;
2620
+ const env = options.env ?? process.env;
2621
+ if (env.GAJAEWAY_BIN_DIR)
2622
+ return env.GAJAEWAY_BIN_DIR;
2623
+ const executable = await realpath(process.execPath).catch(() => {
2624
+ return;
2625
+ });
2626
+ if (executable !== undefined && basename(executable).startsWith("gajaeway"))
2627
+ return dirname2(executable);
2628
+ throw new SetupError("setup --broker-agent requires the installed gajaeway binary or an explicit bin directory");
2629
+ }
2088
2630
  async function runSetup(args, options) {
2089
2631
  const log = options.log ?? ((line) => console.log(line));
2090
2632
  const parsed = parseSetupArgs(args);
@@ -2092,6 +2634,26 @@ async function runSetup(args, options) {
2092
2634
  log((await enabledAdapters(options.home)).join(","));
2093
2635
  return;
2094
2636
  }
2637
+ if (parsed.mode === "broker-agent") {
2638
+ const command = [
2639
+ join8(await setupBinDir(options), "gajaeway-gateway"),
2640
+ "broker-agent",
2641
+ "provision",
2642
+ "--mode",
2643
+ "force",
2644
+ ...parsed.from === undefined ? [] : ["--from", parsed.from]
2645
+ ];
2646
+ const result = await (options.runBrokerAgent ?? defaultBrokerAgentRunner)(command);
2647
+ if (result.stdout.length > 0)
2648
+ process.stdout.write(result.stdout);
2649
+ if (result.stderr.length > 0)
2650
+ process.stderr.write(result.stderr);
2651
+ if (result.exitCode === 1 && /lease_held name=broker/.test(`${result.stdout}
2652
+ ${result.stderr}`))
2653
+ log("broker lease is held; stop it with: systemctl --user stop gajaeway-broker");
2654
+ process.exitCode = result.exitCode;
2655
+ return;
2656
+ }
2095
2657
  let tokens;
2096
2658
  let owners;
2097
2659
  if (parsed.fromEnv) {
@@ -2100,7 +2662,7 @@ async function runSetup(args, options) {
2100
2662
  } else {
2101
2663
  const prompter = (options.prompter ?? terminalPrompter)();
2102
2664
  try {
2103
- ({ tokens, owners } = await tokensFromPrompts(prompter, parsed));
2665
+ ({ tokens, owners } = await tokensFromPrompts(prompter, parsed, log));
2104
2666
  } finally {
2105
2667
  prompter.close();
2106
2668
  }
@@ -2110,6 +2672,11 @@ async function runSetup(args, options) {
2110
2672
  const identities = await validateAll(tokens, options.fetch ?? ((input, init) => fetch(input, init)));
2111
2673
  for (const identity of identities)
2112
2674
  log(`${identity.platform}: token valid \u2014 ${identity.summary}`);
2675
+ const discordIdentity = identities.find((i) => i.platform === "discord");
2676
+ if (discordIdentity && tokens.discord && discordIdentity.appId) {
2677
+ const botName = (discordIdentity.summary.match(/named (\S+)/) ?? [])[1];
2678
+ printDiscordBotReady(log, discordIdentity.appId, botName);
2679
+ }
2113
2680
  for (const identity of identities)
2114
2681
  if (identity.ownerId && owners.length === 0) {
2115
2682
  owners.push(identity.ownerId);
@@ -2129,72 +2696,146 @@ async function runSetup(args, options) {
2129
2696
 
2130
2697
  // src/update.ts
2131
2698
  import {
2132
- chmod as chmod3,
2699
+ chmod as chmod5,
2133
2700
  copyFile,
2134
- mkdir as mkdir5,
2701
+ mkdir as mkdir7,
2702
+ mkdtemp as mkdtemp2,
2703
+ readdir as readdir3,
2704
+ readFile as readFile7,
2705
+ realpath as realpath2,
2706
+ rename as rename5,
2707
+ rm as rm4,
2708
+ stat as stat2,
2709
+ writeFile as writeFile6
2710
+ } from "fs/promises";
2711
+ import { tmpdir } from "os";
2712
+ import { basename as basename2, dirname as dirname3, join as join10 } from "path";
2713
+
2714
+ // src/gjc-install.ts
2715
+ import { randomUUID as randomUUID4 } from "crypto";
2716
+ import {
2717
+ access as access2,
2718
+ chmod as chmod4,
2719
+ lstat as lstat2,
2720
+ mkdir as mkdir6,
2135
2721
  mkdtemp,
2136
- open,
2137
2722
  readdir as readdir2,
2138
- readFile as readFile6,
2139
- realpath,
2140
- rename as rename3,
2141
- rm as rm2,
2142
- stat as stat2,
2723
+ readlink as readlink2,
2724
+ rename as rename4,
2725
+ rm as rm3,
2726
+ symlink,
2143
2727
  writeFile as writeFile5
2144
2728
  } from "fs/promises";
2145
- import { tmpdir } from "os";
2146
- import { basename, dirname, join as join8 } from "path";
2147
- // package.json
2148
- var package_default = {
2149
- name: "@gajae-gateway/cli",
2150
- version: "0.1.7",
2151
- type: "module",
2152
- description: "Owner CLI for the gajaeway gateway: status, sessions, chat, monitors, and worker lanes over the gateway socket. Requires the Bun runtime (uses Bun.file/Bun.spawn).",
2153
- license: "AGPL-3.0-only",
2154
- author: "Yeachan Heo",
2155
- repository: {
2156
- type: "git",
2157
- url: "git+https://github.com/Yeachan-Heo/gajae-way.git",
2158
- directory: "packages/cli"
2159
- },
2160
- homepage: "https://github.com/Yeachan-Heo/gajae-way/tree/main/packages/cli#readme",
2161
- bugs: "https://github.com/Yeachan-Heo/gajae-way/issues",
2162
- keywords: [
2163
- "gajaeway",
2164
- "cli",
2165
- "gateway"
2166
- ],
2167
- files: [
2168
- "dist",
2169
- "src"
2170
- ],
2171
- main: "./dist/main.js",
2172
- types: "./src/main.ts",
2173
- exports: {
2174
- ".": {
2175
- bun: "./src/main.ts",
2176
- types: "./src/main.ts",
2177
- default: "./dist/main.js"
2729
+ import { join as join9 } from "path";
2730
+ var VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
2731
+ async function defaultRunner(command, cwd) {
2732
+ const child = Bun.spawn([...command], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
2733
+ const [stdout, stderr, exitCode] = await Promise.all([
2734
+ new Response(child.stdout).text(),
2735
+ new Response(child.stderr).text(),
2736
+ child.exited
2737
+ ]);
2738
+ return { exitCode, stdout, stderr };
2739
+ }
2740
+ function assertSuccessful(command, result) {
2741
+ if (result.exitCode !== 0) {
2742
+ const detail = result.stderr.trim();
2743
+ throw new Error(`${command.join(" ")} exited with status ${result.exitCode}${detail.length === 0 ? "" : `: ${detail}`}`);
2744
+ }
2745
+ }
2746
+ function versionFromOutput(output) {
2747
+ return output.match(/\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/)?.[0];
2748
+ }
2749
+ async function verifyGjc(path, cwd, version, runner) {
2750
+ await access2(path);
2751
+ const command = [path, "--version"];
2752
+ const result = await runner(command, cwd);
2753
+ assertSuccessful(command, result);
2754
+ const reported = versionFromOutput(`${result.stdout}
2755
+ ${result.stderr}`);
2756
+ if (reported !== version)
2757
+ throw new Error(`gjc version verification failed: expected ${version}, got ${reported ?? "unknown"}`);
2758
+ }
2759
+ async function currentVersion(prefix, current) {
2760
+ try {
2761
+ const target = await readlink2(current);
2762
+ if (target.includes("/") || !VERSION_PATTERN.test(target))
2763
+ return;
2764
+ const targetPath = join9(prefix, target);
2765
+ if (!(await lstat2(targetPath)).isDirectory())
2766
+ return;
2767
+ return target;
2768
+ } catch {
2769
+ return;
2770
+ }
2771
+ }
2772
+ async function pruneVersions(prefix, keep) {
2773
+ const retained = new Set(keep);
2774
+ const entries2 = await readdir2(prefix, { withFileTypes: true });
2775
+ const versions = entries2.filter((entry) => entry.isDirectory() && VERSION_PATTERN.test(entry.name)).map((entry) => entry.name).sort((left, right) => Bun.semver.order(right, left));
2776
+ for (const version of versions) {
2777
+ if (retained.size >= 2)
2778
+ break;
2779
+ retained.add(version);
2780
+ }
2781
+ for (const version of versions)
2782
+ if (!retained.has(version))
2783
+ await rm3(join9(prefix, version), { recursive: true, force: true });
2784
+ }
2785
+ async function installPinnedGjc(options) {
2786
+ const { home, version } = options;
2787
+ if (!VERSION_PATTERN.test(version))
2788
+ throw new Error(`invalid pinned gjc version: ${version}`);
2789
+ const layout = homeLayout(home);
2790
+ const runner = options.runner ?? defaultRunner;
2791
+ await mkdir6(layout.gjcPrefix, { recursive: true, mode: 448 });
2792
+ await chmod4(layout.gjcPrefix, 448);
2793
+ const versionDir = join9(layout.gjcPrefix, version);
2794
+ const previousVersion = await currentVersion(layout.gjcPrefix, layout.gjcCurrent);
2795
+ let staging;
2796
+ try {
2797
+ let installed = false;
2798
+ try {
2799
+ const info = await lstat2(versionDir);
2800
+ if (!info.isDirectory())
2801
+ throw new Error(`gjc version path is not a directory: ${versionDir}`);
2802
+ installed = true;
2803
+ } catch (error) {
2804
+ if (error.code !== "ENOENT")
2805
+ throw error;
2178
2806
  }
2179
- },
2180
- bin: {
2181
- gajaeway: "./dist/main.js"
2182
- },
2183
- publishConfig: {
2184
- access: "public"
2185
- },
2186
- scripts: {
2187
- build: "rm -rf dist && bun build src/main.ts --outdir dist --target bun --format esm --external @gajae-gateway/protocol --external @gajae-gateway/sdk && tsc -p tsconfig.build.json",
2188
- prepublishOnly: "bun run build"
2189
- },
2190
- dependencies: {
2191
- "@gajae-gateway/sdk": "workspace:*",
2192
- "@gajae-gateway/protocol": "workspace:*"
2193
- },
2194
- engines: {
2195
- bun: ">=1.4.0"
2807
+ if (!installed) {
2808
+ staging = await mkdtemp(join9(layout.gjcPrefix, ".staging-"));
2809
+ await chmod4(staging, 448);
2810
+ await writeFile5(join9(staging, "package.json"), `${JSON.stringify({ private: true, dependencies: { "gajae-code": version } }, null, 2)}
2811
+ `, { mode: 384 });
2812
+ const installCommand = ["bun", "install", "--production"];
2813
+ assertSuccessful(installCommand, await runner(installCommand, staging));
2814
+ const installedBin = join9(staging, "node_modules", ".bin", "gjc");
2815
+ await access2(installedBin);
2816
+ const binDir = join9(staging, "bin");
2817
+ await mkdir6(binDir, { mode: 448 });
2818
+ await symlink("../node_modules/.bin/gjc", join9(binDir, "gjc"), "file");
2819
+ await verifyGjc(join9(binDir, "gjc"), staging, version, runner);
2820
+ await rename4(staging, versionDir);
2821
+ staging = undefined;
2822
+ } else {
2823
+ await verifyGjc(join9(versionDir, "bin", "gjc"), versionDir, version, runner);
2824
+ }
2825
+ await pruneVersions(layout.gjcPrefix, [version, ...previousVersion === undefined ? [] : [previousVersion]]);
2826
+ const temporaryLink = join9(layout.gjcPrefix, `.current-${randomUUID4()}`);
2827
+ try {
2828
+ await symlink(version, temporaryLink, "dir");
2829
+ await rename4(temporaryLink, layout.gjcCurrent);
2830
+ } finally {
2831
+ await rm3(temporaryLink, { force: true });
2832
+ }
2833
+ return layout.gjcExecutable();
2834
+ } finally {
2835
+ if (staging !== undefined)
2836
+ await rm3(staging, { recursive: true, force: true });
2196
2837
  }
2197
- };
2838
+ }
2198
2839
 
2199
2840
  // src/update.ts
2200
2841
  var UPDATE_USAGE = "usage: gajaeway update [--check] [--force] [--bin-dir DIR] [--no-restart]";
@@ -2262,13 +2903,12 @@ var CORE_BINARIES = ["gajaeway", "gajaeway-gateway"];
2262
2903
  var MIN_BINARY_BYTES = 1048576;
2263
2904
  var MAX_ASSET_BYTES = 512 * 1024 * 1024;
2264
2905
  var RELEASE_MARKER = ".gajaeway-release";
2265
- var LOCK_FILE = ".gajaeway-update.lock";
2266
2906
  function releaseMarkerPath(binDir) {
2267
- return join8(binDir, RELEASE_MARKER);
2907
+ return join10(binDir, RELEASE_MARKER);
2268
2908
  }
2269
2909
  async function readInstalledRelease(binDir) {
2270
2910
  try {
2271
- const version = releaseVersion(await readFile6(releaseMarkerPath(binDir), "utf8"));
2911
+ const version = releaseVersion(await readFile7(releaseMarkerPath(binDir), "utf8"));
2272
2912
  return version;
2273
2913
  } catch {
2274
2914
  return;
@@ -2277,12 +2917,12 @@ async function readInstalledRelease(binDir) {
2277
2917
  async function resolveBinDir(flag, execPath = process.execPath) {
2278
2918
  if (flag !== undefined)
2279
2919
  return flag;
2280
- const real = await realpath(execPath).catch(() => {
2920
+ const real = await realpath2(execPath).catch(() => {
2281
2921
  return;
2282
2922
  });
2283
2923
  if (real === undefined)
2284
2924
  return;
2285
- return basename(real).startsWith("gajaeway") ? dirname(real) : undefined;
2925
+ return basename2(real).startsWith("gajaeway") ? dirname3(real) : undefined;
2286
2926
  }
2287
2927
  function renderUpdate(result) {
2288
2928
  const lines = [];
@@ -2302,8 +2942,71 @@ function renderUpdate(result) {
2302
2942
  lines.push(`restart: queued ${result.restartQueued} \u2014 check with: gajaeway ops restart-stack --status`);
2303
2943
  else if (result.restartSkipped)
2304
2944
  lines.push("restart: skipped (--no-restart)");
2945
+ if (result.brokerNotEnabled)
2946
+ lines.push("broker unit installed, not enabled: run gajaeway setup --broker-agent");
2305
2947
  return lines;
2306
2948
  }
2949
+ async function readPinnedGjcVersion(path) {
2950
+ let parsed;
2951
+ try {
2952
+ parsed = JSON.parse(await readFile7(path, "utf8"));
2953
+ } catch (error) {
2954
+ throw new Error(`could not read pinned gjc version from ${path}: ${error instanceof Error ? error.message : String(error)}`);
2955
+ }
2956
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
2957
+ throw new Error(`invalid release package metadata at ${path}`);
2958
+ const gjc = parsed.gjc;
2959
+ if (typeof gjc !== "object" || gjc === null || Array.isArray(gjc))
2960
+ throw new Error(`release package metadata at ${path} has no gjc.version`);
2961
+ const version = gjc.version;
2962
+ if (typeof version !== "string" || version.trim() === "")
2963
+ throw new Error(`release package metadata at ${path} has no gjc.version`);
2964
+ return version;
2965
+ }
2966
+ async function defaultUpdateCommandRunner(command) {
2967
+ const child = Bun.spawn([...command], { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
2968
+ const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited]);
2969
+ if (exitCode !== 0) {
2970
+ const detail = stderr.trim();
2971
+ throw new Error(`${command.join(" ")} exited with status ${exitCode}${detail.length === 0 ? "" : `: ${detail}`}`);
2972
+ }
2973
+ return exitCode;
2974
+ }
2975
+ async function installUpdatedServices(options) {
2976
+ const { home, binDir, platform, noRestart, deps } = options;
2977
+ const brokerProvisioned = await exists(homeLayout(home).brokerProvisionManifest);
2978
+ const env = { ...deps.env ?? process.env, GAJAEWAY_HOME: home };
2979
+ await installServices({
2980
+ binDir,
2981
+ platform,
2982
+ env,
2983
+ configuredAdaptersOnly: true,
2984
+ compareAndWrite: true,
2985
+ ...deps.serviceOptions
2986
+ });
2987
+ if (platform === "linux") {
2988
+ const run = deps.serviceRunner ?? defaultUpdateCommandRunner;
2989
+ const execute = async (command) => {
2990
+ const exitCode = await run(command);
2991
+ if (exitCode !== 0)
2992
+ throw new Error(`${command.join(" ")} exited with status ${exitCode}`);
2993
+ };
2994
+ await execute(["systemctl", "--user", "daemon-reload"]);
2995
+ if (!noRestart) {
2996
+ const enableUnits = [];
2997
+ for (const spec of serviceSpecs()) {
2998
+ if (spec.id === "broker" && !brokerProvisioned)
2999
+ continue;
3000
+ if (spec.id.startsWith("adapter-") && !await Bun.file(join10(home, `adapter-${spec.id.slice(8)}.json`)).exists())
3001
+ continue;
3002
+ enableUnits.push(systemdUnitName(spec));
3003
+ }
3004
+ if (enableUnits.length > 0)
3005
+ await execute(["systemctl", "--user", "enable", ...enableUnits]);
3006
+ }
3007
+ }
3008
+ return !brokerProvisioned;
3009
+ }
2307
3010
  async function runUpdate(options) {
2308
3011
  const deps = options.deps ?? {};
2309
3012
  const platform = deps.platform ?? process.platform;
@@ -2326,46 +3029,66 @@ async function runUpdate(options) {
2326
3029
  return { current, latest, tag: release.tag, repo, checkedOnly: true, upToDate: false, installed: [] };
2327
3030
  if (binDir === undefined)
2328
3031
  throw new Error("run the update from a compiled gajaeway binary, or pass --bin-dir DIR");
2329
- const unlock = await acquireUpdateLock(binDir);
3032
+ let lease;
2330
3033
  try {
2331
- const staged = await stageRelease(asset, deps);
2332
- const installedNames = await knownBinariesIn(binDir);
2333
- const toInstall = binariesToInstall(staged.names, installedNames);
2334
- const missingCore = CORE_BINARIES.filter((name) => !toInstall.includes(name));
2335
- if (missingCore.length > 0)
2336
- throw new Error(`release tarball is missing core binaries: ${missingCore.join(", ")}`);
2337
- await assertStagedBinariesUsable(staged, toInstall);
2338
- const stamp = new Date((deps.now ?? Date.now)()).toISOString().replace(/[-:]/g, "").slice(0, 15);
2339
- const backupDir = join8(binDir, `backup-${current}-${stamp}Z`);
2340
- await mkdir5(backupDir, { recursive: false });
2341
- for (const name of toInstall) {
2342
- const target = join8(binDir, name);
2343
- if (await exists(target))
2344
- await copyFile(target, join8(backupDir, name));
2345
- }
2346
- for (const name of toInstall) {
2347
- const stagedPath = join8(staged.dir, name);
2348
- const tempPath = join8(binDir, `.update-${name}`);
2349
- await copyFile(stagedPath, tempPath);
2350
- await chmod3(tempPath, 493);
2351
- await rename3(tempPath, join8(binDir, name));
3034
+ lease = await acquireLease(options.home, "update");
3035
+ console.info(`lease_acquired name=update pid=${process.pid}`);
3036
+ } catch (error) {
3037
+ if (error instanceof LeaseHeldError) {
3038
+ console.error(error.message);
3039
+ throw new Error(`another gajaeway update is running (${error.message})`, { cause: error });
2352
3040
  }
2353
- await writeFile5(releaseMarkerPath(binDir), `${latest}
3041
+ console.error(`lease_failed name=update detail=${error instanceof Error ? error.message : String(error)}`);
3042
+ throw error;
3043
+ }
3044
+ const installed = await (async () => {
3045
+ let staged;
3046
+ try {
3047
+ staged = await stageRelease(asset, deps);
3048
+ const installedNames = await knownBinariesIn(binDir);
3049
+ const toInstall = binariesToInstall(staged.names, installedNames);
3050
+ const missingCore = CORE_BINARIES.filter((name) => !toInstall.includes(name));
3051
+ if (missingCore.length > 0)
3052
+ throw new Error(`release tarball is missing core binaries: ${missingCore.join(", ")}`);
3053
+ await assertStagedBinariesUsable(staged, toInstall);
3054
+ const gjcVersion = await readPinnedGjcVersion(join10(staged.dir, "package.json"));
3055
+ await installPinnedGjc({
3056
+ home: options.home,
3057
+ version: gjcVersion,
3058
+ ...deps.gjcInstallRunner === undefined ? {} : { runner: deps.gjcInstallRunner }
3059
+ });
3060
+ const stamp = new Date((deps.now ?? Date.now)()).toISOString().replace(/[-:]/g, "").slice(0, 15);
3061
+ const backupDir = join10(binDir, `backup-${current}-${stamp}Z`);
3062
+ await mkdir7(backupDir, { recursive: false });
3063
+ for (const name of toInstall) {
3064
+ const target = join10(binDir, name);
3065
+ if (await exists(target))
3066
+ await copyFile(target, join10(backupDir, name));
3067
+ }
3068
+ for (const name of toInstall) {
3069
+ const stagedPath = join10(staged.dir, name);
3070
+ const tempPath = join10(binDir, `.update-${name}`);
3071
+ await copyFile(stagedPath, tempPath);
3072
+ await chmod5(tempPath, 493);
3073
+ await rename5(tempPath, join10(binDir, name));
3074
+ }
3075
+ await writeFile6(releaseMarkerPath(binDir), `${latest}
2354
3076
  `, "utf8");
2355
- await rm2(staged.dir, { recursive: true, force: true });
2356
- if (options.args.noRestart)
2357
- return {
2358
- current,
2359
- latest,
2360
- tag: release.tag,
2361
- repo,
2362
- checkedOnly: false,
2363
- upToDate: false,
2364
- installed: toInstall,
2365
- backupDir,
2366
- restartSkipped: true
2367
- };
2368
- const receipt = await (deps.queueRestart ?? defaultQueueRestart)(options.home);
3077
+ const brokerNotEnabled = await installUpdatedServices({
3078
+ home: options.home,
3079
+ binDir,
3080
+ platform,
3081
+ noRestart: options.args.noRestart,
3082
+ deps
3083
+ });
3084
+ return { toInstall, backupDir, brokerNotEnabled };
3085
+ } finally {
3086
+ if (staged !== undefined)
3087
+ await rm4(staged.dir, { recursive: true, force: true });
3088
+ await lease.release();
3089
+ }
3090
+ })();
3091
+ if (options.args.noRestart)
2369
3092
  return {
2370
3093
  current,
2371
3094
  latest,
@@ -2373,26 +3096,37 @@ async function runUpdate(options) {
2373
3096
  repo,
2374
3097
  checkedOnly: false,
2375
3098
  upToDate: false,
2376
- installed: toInstall,
2377
- backupDir,
2378
- restartQueued: receipt.receiptId
3099
+ installed: installed.toInstall,
3100
+ backupDir: installed.backupDir,
3101
+ brokerNotEnabled: installed.brokerNotEnabled,
3102
+ restartSkipped: true
2379
3103
  };
2380
- } finally {
2381
- await unlock();
2382
- }
3104
+ const receipt = await (deps.queueRestart ?? defaultQueueRestart)(options.home, binDir, "update");
3105
+ return {
3106
+ current,
3107
+ latest,
3108
+ tag: release.tag,
3109
+ repo,
3110
+ checkedOnly: false,
3111
+ upToDate: false,
3112
+ installed: installed.toInstall,
3113
+ backupDir: installed.backupDir,
3114
+ brokerNotEnabled: installed.brokerNotEnabled,
3115
+ restartQueued: receipt.receiptId
3116
+ };
2383
3117
  }
2384
3118
  function binariesToInstall(stagedNames, installedNames) {
2385
3119
  const installed = new Set(installedNames);
2386
3120
  return KNOWN_BINARIES.filter((name) => stagedNames.includes(name) && (installed.has(name) || CORE_BINARIES.includes(name)));
2387
3121
  }
2388
3122
  async function knownBinariesIn(dir) {
2389
- const entries2 = await readdir2(dir).catch(() => []);
3123
+ const entries2 = await readdir3(dir).catch(() => []);
2390
3124
  const names = new Set(entries2);
2391
3125
  return KNOWN_BINARIES.filter((name) => names.has(name));
2392
3126
  }
2393
3127
  async function assertStagedBinariesUsable(staged, toInstall) {
2394
3128
  for (const name of toInstall) {
2395
- const info = await stat2(join8(staged.dir, name));
3129
+ const info = await stat2(join10(staged.dir, name));
2396
3130
  if (!info.isFile())
2397
3131
  throw new Error(`staged ${name} is not a regular file`);
2398
3132
  if (info.size < MIN_BINARY_BYTES)
@@ -2408,10 +3142,10 @@ async function exists(path) {
2408
3142
  }
2409
3143
  }
2410
3144
  async function stageRelease(asset, deps) {
2411
- const dir = await mkdtemp(join8(tmpdir(), "gajaeway-update-"));
3145
+ const dir = await mkdtemp2(join10(tmpdir(), "gajaeway-update-"));
2412
3146
  const tarball = await (deps.downloadAsset ?? defaultDownloadAsset)(asset);
2413
3147
  await (deps.extractTarball ?? defaultExtractTarball)(tarball, dir);
2414
- const names = (await readdir2(dir)).filter((name) => KNOWN_BINARIES.includes(name));
3148
+ const names = (await readdir3(dir)).filter((name) => KNOWN_BINARIES.includes(name));
2415
3149
  return { dir, names };
2416
3150
  }
2417
3151
  async function defaultFetchLatestRelease(repo) {
@@ -2437,8 +3171,8 @@ async function defaultDownloadAsset(asset) {
2437
3171
  return bytes;
2438
3172
  }
2439
3173
  async function defaultExtractTarball(tarball, intoDir) {
2440
- const tarballPath = join8(intoDir, "release.tar.gz");
2441
- await writeFile5(tarballPath, tarball);
3174
+ const tarballPath = join10(intoDir, "release.tar.gz");
3175
+ await writeFile6(tarballPath, tarball);
2442
3176
  const process2 = Bun.spawn(["tar", "-xzf", tarballPath, "-C", intoDir], {
2443
3177
  stdout: "ignore",
2444
3178
  stderr: "pipe"
@@ -2446,186 +3180,19 @@ async function defaultExtractTarball(tarball, intoDir) {
2446
3180
  const exitCode = await process2.exited;
2447
3181
  if (exitCode !== 0) {
2448
3182
  const detail = await new Response(process2.stderr).text();
2449
- throw new Error(`extracting ${basename(tarballPath)} failed (tar exit ${exitCode}): ${detail.trim()}`);
3183
+ throw new Error(`extracting ${basename2(tarballPath)} failed (tar exit ${exitCode}): ${detail.trim()}`);
2450
3184
  }
2451
- await rm2(tarballPath, { force: true });
3185
+ await rm4(tarballPath, { force: true });
2452
3186
  }
2453
- async function defaultQueueRestart(home) {
2454
- const { receipt, supervisorPid } = await launchRestartStack({ home });
3187
+ async function defaultQueueRestart(home, binDir, trigger) {
3188
+ const { receipt, supervisorPid } = await launchRestartStack({ home, binDir, trigger });
2455
3189
  return { receiptId: receipt.id, supervisorPid };
2456
3190
  }
2457
- async function acquireUpdateLock(binDir) {
2458
- const lockPath = join8(binDir, LOCK_FILE);
2459
- for (let attempt = 0;attempt < 2; attempt++) {
2460
- try {
2461
- const handle = await open(lockPath, "wx");
2462
- try {
2463
- await handle.write(`${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}
2464
- `);
2465
- } finally {
2466
- await handle.close();
2467
- }
2468
- return async () => {
2469
- await rm2(lockPath, { force: true });
2470
- };
2471
- } catch (error) {
2472
- if (!isFileExistsError(error))
2473
- throw error;
2474
- const holder = await readLockHolder(lockPath);
2475
- if (holder !== undefined && isProcessAlive(holder)) {
2476
- throw new Error(`another gajaeway update is running (pid ${holder}); refusing to run concurrently`);
2477
- }
2478
- await rm2(lockPath, { force: true });
2479
- }
2480
- }
2481
- throw new Error(`could not acquire the update lock at ${lockPath}`);
2482
- }
2483
- function isFileExistsError(error) {
2484
- return error !== null && typeof error === "object" && error.code === "EEXIST";
2485
- }
2486
- async function readLockHolder(lockPath) {
2487
- try {
2488
- const parsed = JSON.parse(await readFile6(lockPath, "utf8"));
2489
- return typeof parsed.pid === "number" ? parsed.pid : undefined;
2490
- } catch {
2491
- return;
2492
- }
2493
- }
2494
- function isProcessAlive(pid) {
2495
- try {
2496
- process.kill(pid, 0);
2497
- return true;
2498
- } catch (error) {
2499
- return error.code === "EPERM";
2500
- }
2501
- }
2502
-
2503
- // src/upgrade.ts
2504
- import { readFile as readFile7 } from "fs/promises";
2505
- import { dirname as dirname2, join as join9 } from "path";
2506
- async function readGatewayPinnedGjcVersion(home) {
2507
- const candidates = [
2508
- join9(home, "..", "..", "packages", "gateway", "package.json"),
2509
- join9(dirname2(process.execPath), "package.json")
2510
- ];
2511
- for (const path of candidates) {
2512
- try {
2513
- const version = JSON.parse(await readFile7(path, "utf-8")).gjc?.version;
2514
- if (typeof version === "string" && version.length > 0)
2515
- return version;
2516
- } catch {}
2517
- }
2518
- throw new Error(`Failed to read pinned GJC version from any of ${candidates.join(", ")}`);
2519
- }
2520
- async function checkLatestCiStatus(repo) {
2521
- try {
2522
- const branchProc = Bun.spawnSync(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
2523
- stdout: "pipe",
2524
- stderr: "pipe"
2525
- });
2526
- if (branchProc.exitCode !== 0) {
2527
- throw new Error("Could not determine current git branch");
2528
- }
2529
- const branch = branchProc.stdout?.toString().trim();
2530
- if (!branch) {
2531
- throw new Error("Empty branch name");
2532
- }
2533
- const runProc = Bun.spawnSync(["gh", "run", "list", "-R", repo, "--branch", branch, "--limit", "1", "--json", "conclusion,status"], {
2534
- stdout: "pipe",
2535
- stderr: "pipe"
2536
- });
2537
- if (runProc.exitCode !== 0) {
2538
- throw new Error(`gh run list failed: ${runProc.stderr?.toString()}`);
2539
- }
2540
- const output = runProc.stdout?.toString() || "[]";
2541
- const runs = JSON.parse(output);
2542
- if (!Array.isArray(runs) || runs.length === 0) {
2543
- throw new Error("No CI runs found for this branch");
2544
- }
2545
- const latestRun = runs[0];
2546
- if (!latestRun.conclusion || !latestRun.status) {
2547
- throw new Error(`Latest CI run status is incomplete: ${JSON.stringify(latestRun)}`);
2548
- }
2549
- if (latestRun.status === "in_progress") {
2550
- throw new Error("CI is currently running; wait for completion");
2551
- }
2552
- return latestRun.conclusion === "success";
2553
- } catch (error) {
2554
- throw new Error(`Failed to check CI status: ${error instanceof Error ? error.message : String(error)}`);
2555
- }
2556
- }
2557
- async function performUpgrade(options) {
2558
- const readVersion = options.readPinnedVersion || readGatewayPinnedGjcVersion;
2559
- const targetVersion = options.gjcVersion ?? await readVersion(options.home);
2560
- try {
2561
- const checkCi = options.checkCiStatus || checkLatestCiStatus;
2562
- let ciPassed = false;
2563
- try {
2564
- ciPassed = await checkCi("Yeachan-Heo/gajae-way");
2565
- } catch (ciError) {
2566
- const ciDetail = ciError instanceof Error ? ciError.message : String(ciError);
2567
- return { status: "failed", detail: `CI status check failed: ${ciDetail}` };
2568
- }
2569
- if (!ciPassed) {
2570
- return {
2571
- status: "failed",
2572
- detail: `Cannot upgrade: latest CI run did not pass. Fix failures and try again.`
2573
- };
2574
- }
2575
- const gjcProc = Bun.spawnSync(["gjc", "--version"], {
2576
- stdout: "pipe",
2577
- stderr: "pipe"
2578
- });
2579
- if (gjcProc.exitCode !== 0) {
2580
- return { status: "failed", detail: "Failed to get current gjc version" };
2581
- }
2582
- const currentVersionMatch = ((gjcProc.stdout?.toString() || "") + (gjcProc.stderr?.toString() || "")).match(/(?:gjc\/)?(\d+)\.(\d+)\.(\d+)/);
2583
- if (!currentVersionMatch) {
2584
- return { status: "failed", detail: "Could not parse current gjc version" };
2585
- }
2586
- const currentVersion = currentVersionMatch.slice(1, 4).join(".");
2587
- if (currentVersion === targetVersion) {
2588
- return { status: "ok", detail: `Already running gjc ${targetVersion}` };
2589
- }
2590
- const installProc = Bun.spawnSync(["bun", "add", "-g", `gajae-code@${targetVersion}`], {
2591
- stdout: "pipe",
2592
- stderr: "pipe",
2593
- cwd: join9(options.home, "workspace")
2594
- });
2595
- if (installProc.exitCode !== 0) {
2596
- return {
2597
- status: "failed",
2598
- detail: `Failed to install gjc ${targetVersion}: ${installProc.stderr?.toString()}`
2599
- };
2600
- }
2601
- const verifyProc = Bun.spawnSync(["gjc", "--version"], {
2602
- stdout: "pipe",
2603
- stderr: "pipe"
2604
- });
2605
- if (verifyProc.exitCode !== 0) {
2606
- return { status: "failed", detail: "Failed to verify gjc installation" };
2607
- }
2608
- const verifiedVersionMatch = ((verifyProc.stdout?.toString() || "") + (verifyProc.stderr?.toString() || "")).match(/(?:gjc\/)?(\d+)\.(\d+)\.(\d+)/);
2609
- if (!verifiedVersionMatch) {
2610
- return { status: "failed", detail: "Could not verify installed gjc version" };
2611
- }
2612
- const verifiedVersion = verifiedVersionMatch.slice(1, 4).join(".");
2613
- if (verifiedVersion !== targetVersion) {
2614
- return {
2615
- status: "failed",
2616
- detail: `Verification failed: expected ${targetVersion}, got ${verifiedVersion}`
2617
- };
2618
- }
2619
- return { status: "ok", detail: `Successfully upgraded gjc from ${currentVersion} to ${targetVersion}` };
2620
- } catch (error) {
2621
- return {
2622
- status: "failed",
2623
- detail: `Upgrade failed: ${error instanceof Error ? error.message : String(error)}`
2624
- };
2625
- }
2626
- }
2627
3191
 
2628
3192
  // src/main.ts
3193
+ async function connectGatewaySocket(path, options = {}) {
3194
+ return GajaewayClient.connectSocketWithBackoff(path, { ...options, deadlineMs: 30000 });
3195
+ }
2629
3196
  function socketPath(home = process.env.GAJAEWAY_HOME) {
2630
3197
  return `${home ?? `${process.env.HOME ?? "~"}/.gajaeway`}/gateway.sock`;
2631
3198
  }
@@ -2644,7 +3211,7 @@ var COMMANDS = [
2644
3211
  "update",
2645
3212
  "setup"
2646
3213
  ];
2647
- var CLI_USAGE = "usage: gajaeway [--socket PATH] status|shutdown|chat|daemon run|sessions list [--json] [--fields a,b,c] [--limit N] [--offset N]|sessions inspect <originKey-or-index>|memory audit|memory search <query>|monitors ... (test <id> [--type T] [--payload J] [--wait[=SECONDS]])|work run|start <name> [--cwd DIR] [--resume] [--model ID|--preset NAME] [--notify originKey (start only)] <text>|work status <name>|work steer <name> <text>|work retire [--force] <name>|work retire --all-dead|work jobs|ops backup <path>|ops redeliver <deliveryId>|ops redeliver --since <iso>|ops cycle [--json]|ops integrity|ops restore <backupPath>|ops restart-stack [--status]|services install|repair --bin-dir DIR [--launch-agents-dir DIR] [--unit-dir DIR] [--platform darwin|linux]|migrate [--source PATH] [--target PATH] [--dry-run]|update [--check] [--force] [--bin-dir DIR] [--no-restart]|setup [--from-env] [--adapters discord,slack,telegram] [--owner ID] [--discord-app-id ID]|setup --status (work run waits for a response; caller timeout does not end the attempt); cron timezone is an IANA zone and defaults to the gateway host's local timezone";
3214
+ var CLI_USAGE = "usage: gajaeway [--version] [--socket PATH] status|shutdown|chat|daemon run|sessions list [--json] [--fields a,b,c] [--limit N] [--offset N]|sessions inspect <originKey-or-index>|memory audit|memory search <query>|monitors ... (test <id> [--type T] [--payload J] [--wait[=SECONDS]])|work run|start <name> [--cwd DIR] [--resume] [--model ID|--preset NAME] [--notify originKey (start only)] <text>|work status <name>|work steer <name> <text>|work retire [--force] <name>|work retire --all-dead|work jobs [--limit N] [--offset N]|ops backup <path>|ops deliveries [--since ISO] [--state S] [--limit N] [--json]|ops redeliver <deliveryId>|ops redeliver --since <iso>|ops cycle [--json]|ops integrity|ops restart-stack [--status]|services install|repair --bin-dir DIR [--launch-agents-dir DIR] [--unit-dir DIR] [--platform darwin|linux]|migrate [--source PATH] [--target PATH] [--dry-run]|update [--check] [--force] [--bin-dir DIR] [--no-restart]|setup [--from-env] [--adapters discord,slack,telegram] [--owner ID] [--discord-app-id ID]|setup --status|setup --broker-agent [--from DIR] (work run waits for a response; caller timeout does not end the attempt); cron timezone is an IANA zone and defaults to the gateway host's local timezone";
2648
3215
  var USAGE_EXIT_CODE = 2;
2649
3216
  function usageFor(command) {
2650
3217
  return command !== undefined && COMMANDS.includes(command) ? undefined : CLI_USAGE;
@@ -2848,11 +3415,23 @@ function renderCycle(cycle) {
2848
3415
  lines.push(`context: unread=${cycle.contextDiff.unread} expired=${cycle.contextDiff.expired} truncated=${cycle.contextDiff.truncated} omitted_oldest=${cycle.contextDiff.omittedOldestAt ?? "-"} omitted_newest=${cycle.contextDiff.omittedNewestAt ?? "-"}`);
2849
3416
  lines.push(`deliveries: pending=${cycle.deliveries.pending} inflight=${cycle.deliveries.inflight} confirmed=${cycle.deliveries.confirmed} failed_ambiguous=${cycle.deliveries.failedAmbiguous} expired=${cycle.deliveries.expired}`);
2850
3417
  lines.push(`memory: queued=${cycle.memoryIntents.queued} written=${cycle.memoryIntents.written} committed=${cycle.memoryIntents.committed} receipted=${cycle.memoryIntents.receipted} quarantined=${cycle.memoryIntents.quarantined}${cycle.memoryClosing ? " (closing)" : ""}`);
3418
+ if (cycle.memoryGitIndexLock) {
3419
+ const { path, ageMs } = cycle.memoryGitIndexLock;
3420
+ lines.push(`memory_git_index_lock: ${path} age=${Math.round(ageMs)}ms`);
3421
+ }
2851
3422
  if (cycle.agentDisk) {
2852
3423
  const { path, freeBytes, totalBytes } = cycle.agentDisk;
2853
3424
  const gib = (bytes) => (bytes / 1024 ** 3).toFixed(1);
2854
3425
  lines.push(freeBytes === null || totalBytes === null ? `agent_disk: ${path} unobservable` : `agent_disk: ${path} free=${gib(freeBytes)}GiB total=${gib(totalBytes)}GiB`);
2855
3426
  }
3427
+ if (cycle.brokerAgent !== null) {
3428
+ lines.push(`broker_agent: ${cycle.brokerAgent.status}`);
3429
+ for (const entry of cycle.brokerAgent.entries)
3430
+ if (entry.state !== "in_sync")
3431
+ lines.push(`broker_agent ${entry.name}: kind=${entry.kind} state=${entry.state}`);
3432
+ }
3433
+ const sloState = cycle.slo.ok ? "ok" : cycle.slo.days.some((day) => day.status === "failed") ? "failed" : cycle.slo.days.some((day) => day.status === "insufficient") ? "insufficient" : "pending";
3434
+ lines.push(`slo: seven-day=${sloState}`);
2856
3435
  lines.push(`monitors: ${cycle.monitorEvents.length ? cycle.monitorEvents.map((m) => `${m.stage}=${m.count}`).join(" ") : "none"}`);
2857
3436
  if (cycle.monitorAuthoringLost.length > 0)
2858
3437
  lines.push(`monitor authoring lost: ${cycle.monitorAuthoringLost.map((m) => `${m.eventType}=${m.consecutive} (last ${m.lastFiredAt})`).join(" ")}`);
@@ -2870,82 +3449,110 @@ function renderCycle(cycle) {
2870
3449
  function cycleExitCode(cycle) {
2871
3450
  return cycle.gates.length > 0 ? 1 : 0;
2872
3451
  }
2873
- function verifyBackupIntegrity(backupPath) {
2874
- let database;
2875
- try {
2876
- database = new Database(backupPath, { readonly: true });
2877
- } catch (error) {
2878
- throw new Error(`Backup is not readable: ${backupPath} (${error instanceof Error ? error.message : String(error)})`);
2879
- }
2880
- let detail;
2881
- let pageCount;
2882
- try {
2883
- pageCount = database.query("PRAGMA page_count").get()?.page_count ?? 0;
2884
- detail = database.query("PRAGMA integrity_check").get()?.integrity_check ?? "unknown";
2885
- } catch (error) {
2886
- throw new Error(`Backup is not a readable SQLite database: ${backupPath} (${error instanceof Error ? error.message : String(error)})`);
2887
- } finally {
2888
- database.close();
2889
- }
2890
- if (pageCount === 0)
2891
- throw new Error(`Backup is an empty SQLite database: ${backupPath}`);
2892
- if (detail !== "ok")
2893
- throw new Error(`Backup failed SQLite integrity_check: ${backupPath} (${detail})`);
3452
+ function renderSlo(result) {
3453
+ const message = result.ok ? `SLO ok: ${result.days.length} consecutive UTC days` : result.days.some((day) => day.status === "failed") ? "SLO failed: counted failure events or journal overflow" : result.days.some((day) => day.status === "insufficient") ? "SLO insufficient: coverage or durable evidence is incomplete" : "SLO pending: an update window is still running; rerun later";
3454
+ const lines = [message];
3455
+ for (const day of result.days)
3456
+ lines.push(`${day.date} status=${day.status} coverage=${day.complete ? "complete" : "incomplete"} alive_ms=${day.aliveMs} max_gap_ms=${day.maxUncoveredGapMs} broker_unavailable=${day.broker_unavailable} persona_bind_failed=${day.persona_bind_failed} excluded=${day.excluded}`);
3457
+ for (const window of result.windows)
3458
+ lines.push(`window id=${window.id} trigger=${window.trigger} state=${window.state} requested=${window.requestedAt} finished=${window.finishedAt ?? "-"} verified=${window.verified} eligible=${window.eligibleStart ?? "-"}..${window.eligibleEnd ?? "-"} over_cap_ms=${window.overCapMs}`);
3459
+ return lines;
2894
3460
  }
2895
- async function restoreTargetPath(home = gatewayHome()) {
2896
- const configPath = join10(home, "config.json");
2897
- let raw;
2898
- try {
2899
- raw = await readFile8(configPath, "utf8");
2900
- } catch (error) {
2901
- const code = error.code;
2902
- const entryExists = await lstat2(configPath).then(() => true, () => false);
2903
- if (code !== "ENOENT" || entryExists)
2904
- throw new Error(`Cannot read ${configPath}; refusing restore.`);
2905
- }
2906
- if (raw === undefined)
2907
- return join10(home, "gateway.db");
2908
- let parsed;
2909
- try {
2910
- parsed = JSON.parse(raw);
2911
- } catch {
2912
- throw new Error(`Cannot parse ${configPath}; refusing restore.`);
3461
+ function sloExitCode(result) {
3462
+ return result.ok ? 0 : 1;
3463
+ }
3464
+ var OPS_USAGE = "usage: gajaeway ops backup <path>|deliveries [--since ISO] [--state S] [--limit N] [--json]|redeliver <deliveryId>|redeliver --since <iso>|slo [--days N] [--json]|cycle [--json]|integrity|restart-stack [--status]";
3465
+ function parseOpsSloArgs(args) {
3466
+ let days = 7;
3467
+ let json = false;
3468
+ const seen = new Set;
3469
+ for (let index = 0;index < args.length; index++) {
3470
+ const flag = args[index];
3471
+ if (flag === "--json") {
3472
+ if (json)
3473
+ throw new Error(OPS_USAGE);
3474
+ json = true;
3475
+ continue;
3476
+ }
3477
+ if (flag !== "--days" || seen.has(flag))
3478
+ throw new Error(OPS_USAGE);
3479
+ seen.add(flag);
3480
+ const value = args[++index];
3481
+ if (!value || !/^\d+$/.test(value))
3482
+ throw new Error(OPS_USAGE);
3483
+ days = Number(value);
3484
+ if (!Number.isSafeInteger(days) || days < 1 || days > 3650)
3485
+ throw new Error(OPS_USAGE);
3486
+ }
3487
+ return { params: { days }, json };
3488
+ }
3489
+ function parseOpsDeliveriesArgs(args) {
3490
+ const params = {};
3491
+ const seen = new Set;
3492
+ let json = false;
3493
+ for (let index = 0;index < args.length; index++) {
3494
+ const flag = args[index];
3495
+ if (flag === "--json") {
3496
+ if (json)
3497
+ throw new Error(OPS_USAGE);
3498
+ json = true;
3499
+ continue;
3500
+ }
3501
+ if (flag !== "--since" && flag !== "--state" && flag !== "--limit")
3502
+ throw new Error(OPS_USAGE);
3503
+ if (seen.has(flag))
3504
+ throw new Error(OPS_USAGE);
3505
+ seen.add(flag);
3506
+ const value = args[++index];
3507
+ if (!value || value.startsWith("--"))
3508
+ throw new Error(OPS_USAGE);
3509
+ if (flag === "--since") {
3510
+ if (!Number.isFinite(Date.parse(value)))
3511
+ throw new Error(OPS_USAGE);
3512
+ params.since = value;
3513
+ } else if (flag === "--state")
3514
+ params.state = value;
3515
+ else {
3516
+ if (!/^\d+$/.test(value))
3517
+ throw new Error(OPS_USAGE);
3518
+ const limit = Number(value);
3519
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500)
3520
+ throw new Error(OPS_USAGE);
3521
+ params.limit = limit;
3522
+ }
2913
3523
  }
2914
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
2915
- throw new Error(`Invalid configuration ${configPath}: config must be an object; refusing restore.`);
2916
- const dbPath = parsed.dbPath;
2917
- if (dbPath === undefined)
2918
- return join10(home, "gateway.db");
2919
- if (typeof dbPath !== "string" || dbPath.length === 0)
2920
- throw new Error(`Invalid configuration ${configPath}: dbPath must be a non-empty string; refusing restore.`);
2921
- return dbPath;
2922
- }
2923
- async function restoreDatabase(socket, backupPath) {
2924
- if (!isAbsolute(backupPath))
2925
- throw new Error("ops restore requires an absolute backup path");
2926
- try {
2927
- await stat3(socket);
2928
- throw new Error(`Refusing restore: gateway socket ${socket} exists. Stop the daemon before restoring.`);
2929
- } catch (error) {
2930
- if (error instanceof Error && error.message.startsWith("Refusing restore:"))
2931
- throw error;
2932
- if (error.code !== "ENOENT")
2933
- throw new Error(`Cannot verify gateway socket ${socket}; refusing restore.`);
2934
- }
2935
- verifyBackupIntegrity(backupPath);
2936
- const databasePath = await restoreTargetPath();
2937
- const preservedPath = `${databasePath}.pre-restore-${new Date().toISOString().replace(/[:.]/g, "-")}`;
2938
- await copyFile2(databasePath, preservedPath);
2939
- await copyFile2(backupPath, databasePath);
2940
- console.log(`Validated SQLite backup: ${backupPath}`);
2941
- console.log(`Copied current database to: ${preservedPath}`);
2942
- console.log(`Restored backup to: ${databasePath}`);
3524
+ return { params, json };
3525
+ }
3526
+ function parseWorkJobsArgs(args, usage) {
3527
+ const params = {};
3528
+ for (let index = 0;index < args.length; index++) {
3529
+ const flag = args[index];
3530
+ const value = args[++index];
3531
+ if (flag !== "--limit" && flag !== "--offset" || value === undefined || !/^\d+$/.test(value))
3532
+ throw new Error(usage);
3533
+ const key = flag === "--limit" ? "limit" : "offset";
3534
+ if (params[key] !== undefined)
3535
+ throw new Error(usage);
3536
+ const number = Number(value);
3537
+ if (!Number.isSafeInteger(number) || key === "limit" && (number < 1 || number > WORK_JOBS_MAX_LIMIT))
3538
+ throw new Error(usage);
3539
+ params[key] = number;
3540
+ }
3541
+ return params;
3542
+ }
3543
+ function renderDeliveries(result) {
3544
+ if (result.rows.length === 0)
3545
+ return ["deliveries: none"];
3546
+ return [
3547
+ `deliveries: ${result.rows.length}`,
3548
+ ...result.rows.map((row) => `${row.createdAt} ${row.state} delivery=${row.deliveryId} turn=${row.turnId} origin=${row.originKey} conversation=${row.conversationId} text=${JSON.stringify(row.textPrefix)}`)
3549
+ ];
2943
3550
  }
2944
3551
  var CHAT_TURN_TIMEOUT_MS = 30 * 60000;
2945
3552
  async function chat(socket) {
2946
3553
  let client;
2947
3554
  try {
2948
- client = await GajaewayClient.connectSocket(socket);
3555
+ client = await connectGatewaySocket(socket);
2949
3556
  } catch {
2950
3557
  console.error(`Unable to connect to gateway socket ${socket}. Start the daemon out-of-band first.`);
2951
3558
  process.exitCode = 1;
@@ -3002,6 +3609,10 @@ async function chat(socket) {
3002
3609
  }
3003
3610
  async function main(args = process.argv.slice(2), options = {}) {
3004
3611
  const parsed = parseArgs(args);
3612
+ if (parsed.command === "--version" && parsed.rest.length === 0) {
3613
+ console.log(package_default.version);
3614
+ return;
3615
+ }
3005
3616
  const usage = usageFor(parsed.command);
3006
3617
  if (usage !== undefined) {
3007
3618
  console.error(usage);
@@ -3010,7 +3621,7 @@ async function main(args = process.argv.slice(2), options = {}) {
3010
3621
  try {
3011
3622
  switch (parsed.command) {
3012
3623
  case "status": {
3013
- const client = await GajaewayClient.connectSocket(parsed.socket);
3624
+ const client = await connectGatewaySocket(parsed.socket);
3014
3625
  try {
3015
3626
  console.log(JSON.stringify(await client.status()));
3016
3627
  } finally {
@@ -3019,7 +3630,7 @@ async function main(args = process.argv.slice(2), options = {}) {
3019
3630
  break;
3020
3631
  }
3021
3632
  case "shutdown": {
3022
- const client = await GajaewayClient.connectSocket(parsed.socket);
3633
+ const client = await connectGatewaySocket(parsed.socket);
3023
3634
  try {
3024
3635
  await client.shutdown();
3025
3636
  } finally {
@@ -3038,7 +3649,7 @@ async function main(args = process.argv.slice(2), options = {}) {
3038
3649
  break;
3039
3650
  case "sessions": {
3040
3651
  const listOptions = parsed.rest[0] === "list" ? parseListOptions(parsed.rest.slice(1), SESSION_COLUMNS) : undefined;
3041
- const client = await GajaewayClient.connectSocket(parsed.socket);
3652
+ const client = await connectGatewaySocket(parsed.socket);
3042
3653
  try {
3043
3654
  const result = await client.request("session.list");
3044
3655
  const [command, selector] = parsed.rest;
@@ -3073,30 +3684,12 @@ async function main(args = process.argv.slice(2), options = {}) {
3073
3684
  break;
3074
3685
  }
3075
3686
  case "ops": {
3076
- const [command, path, ...extraArgs] = parsed.rest;
3077
- if (command === "upgrade") {
3078
- const home = gatewayHome();
3079
- let gjcVersion;
3080
- if (path === "--gjc" && extraArgs[0]) {
3081
- gjcVersion = extraArgs[0];
3082
- } else if (path && path !== "--gjc") {
3083
- throw new Error("usage: gajaeway ops upgrade [--gjc X.Y.Z]");
3084
- }
3085
- const result = await performUpgrade({ home, gjcVersion });
3086
- console.log(result.detail);
3087
- if (result.status !== "ok") {
3088
- process.exitCode = 1;
3089
- break;
3090
- }
3091
- const { receipt, supervisorPid } = await launchRestartStack({ home });
3092
- console.log(`restart-stack ${receipt.id}: queued (supervisor pid ${supervisorPid})`);
3093
- console.log("read the outcome with: gajaeway ops restart-stack --status");
3094
- break;
3095
- }
3096
- if (command === "restore" && path) {
3097
- await restoreDatabase(parsed.socket, path);
3098
- break;
3099
- }
3687
+ const [command, path] = parsed.rest;
3688
+ if (command === undefined || !["backup", "deliveries", "redeliver", "integrity", "slo", "cycle", "restart-stack"].includes(command))
3689
+ throw new Error(`unknown ops command: ${command ?? "(missing)"}
3690
+ ${OPS_USAGE}`);
3691
+ const deliveries = command === "deliveries" ? parseOpsDeliveriesArgs(parsed.rest.slice(1)) : undefined;
3692
+ const slo = command === "slo" ? parseOpsSloArgs(parsed.rest.slice(1)) : undefined;
3100
3693
  if (command === "restart-stack") {
3101
3694
  const usage2 = "usage: gajaeway ops restart-stack [--status]";
3102
3695
  const home = gatewayHome();
@@ -3115,9 +3708,10 @@ async function main(args = process.argv.slice(2), options = {}) {
3115
3708
  const receipt = await readRestartReceipt(home);
3116
3709
  if (receipt === undefined)
3117
3710
  throw new Error("no restart-stack receipt");
3118
- for (const line of renderRestartReceipt(receipt))
3711
+ const state = await effectiveRestartState(receipt, home);
3712
+ for (const line of renderRestartReceipt(receipt, state))
3119
3713
  console.log(line);
3120
- if (effectiveRestartState(receipt) !== "ok")
3714
+ if (state !== "ok")
3121
3715
  process.exitCode = 1;
3122
3716
  } else if (parsed.rest.length === 1) {
3123
3717
  const { receipt, supervisorPid } = await launchRestartStack({ ...options.restartStack?.launch, home });
@@ -3136,11 +3730,18 @@ async function main(args = process.argv.slice(2), options = {}) {
3136
3730
  else
3137
3731
  throw new Error("usage: gajaeway ops redeliver <deliveryId>|--since <iso>");
3138
3732
  }
3139
- const client = await GajaewayClient.connectSocket(parsed.socket);
3733
+ const client = await connectGatewaySocket(parsed.socket);
3140
3734
  try {
3141
3735
  if (command === "backup" && path)
3142
3736
  console.log(JSON.stringify(await client.request("ops.backup", { path })));
3143
- else if (redeliverParams)
3737
+ else if (deliveries) {
3738
+ const result = await client.opsDeliveries(deliveries.params);
3739
+ if (deliveries.json)
3740
+ console.log(JSON.stringify(result));
3741
+ else
3742
+ for (const line of renderDeliveries(result))
3743
+ console.log(line);
3744
+ } else if (redeliverParams)
3144
3745
  console.log(JSON.stringify(await client.request("ops.redeliver", redeliverParams)));
3145
3746
  else if (command === "integrity")
3146
3747
  console.log(JSON.stringify(await client.request("ops.integrity")));
@@ -3154,8 +3755,16 @@ async function main(args = process.argv.slice(2), options = {}) {
3154
3755
  for (const line of renderCycle(cycle))
3155
3756
  console.log(line);
3156
3757
  process.exitCode = cycleExitCode(cycle);
3758
+ } else if (slo) {
3759
+ const result = await client.opsSlo(slo.params);
3760
+ if (slo.json)
3761
+ console.log(JSON.stringify(result));
3762
+ else
3763
+ for (const line of renderSlo(result))
3764
+ console.log(line);
3765
+ process.exitCode = sloExitCode(result);
3157
3766
  } else
3158
- throw new Error("usage: gajaeway ops backup <path>|redeliver <deliveryId>|redeliver --since <iso>|cycle [--json]|integrity|restore <backupPath>|upgrade [--gjc X.Y.Z]|restart-stack [--status]");
3767
+ throw new Error(OPS_USAGE);
3159
3768
  } finally {
3160
3769
  await client.close();
3161
3770
  }
@@ -3165,7 +3774,7 @@ async function main(args = process.argv.slice(2), options = {}) {
3165
3774
  const usage2 = "usage: gajaeway memory audit|memory autolink|memory search <query>";
3166
3775
  if ((parsed.rest[0] === "audit" || parsed.rest[0] === "autolink") && parsed.rest.length > 1)
3167
3776
  throw new Error(`${usage2} (unknown argument: ${parsed.rest[1]})`);
3168
- const client = await GajaewayClient.connectSocket(parsed.socket);
3777
+ const client = await connectGatewaySocket(parsed.socket);
3169
3778
  try {
3170
3779
  if (parsed.rest[0] === "audit") {
3171
3780
  const result = await client.request("memory.audit");
@@ -3188,7 +3797,7 @@ async function main(args = process.argv.slice(2), options = {}) {
3188
3797
  const listOptions = command === "list" ? parseListOptions(args2, MONITOR_COLUMNS) : undefined;
3189
3798
  const update = command === "update" ? parseMonitorUpdateArgs(args2) : undefined;
3190
3799
  const monitorTest = command === "test" ? parseMonitorTestArgs(args2) : undefined;
3191
- const client = await GajaewayClient.connectSocket(parsed.socket);
3800
+ const client = await connectGatewaySocket(parsed.socket);
3192
3801
  try {
3193
3802
  if (command === "add" && args2[0] === "--json" && args2[1])
3194
3803
  console.log(JSON.stringify(await client.request("monitor.add", JSON.parse(args2[1]))));
@@ -3238,7 +3847,7 @@ async function main(args = process.argv.slice(2), options = {}) {
3238
3847
  const text2 = args2.slice(1).join(" ").trim();
3239
3848
  if (!name2 || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name2) || (command === "status" ? args2.length !== 1 : !text2 || args2.slice(1).some((arg) => arg.startsWith("--"))))
3240
3849
  throw new Error(usage2);
3241
- const client2 = await GajaewayClient.connectSocket(parsed.socket);
3850
+ const client2 = await connectGatewaySocket(parsed.socket);
3242
3851
  try {
3243
3852
  if (command === "status") {
3244
3853
  console.log(JSON.stringify(await client2.request("work.status", { name: name2 })));
@@ -3257,6 +3866,7 @@ async function main(args = process.argv.slice(2), options = {}) {
3257
3866
  let force = false;
3258
3867
  let allDead = false;
3259
3868
  let name2;
3869
+ let jobsPage;
3260
3870
  if (command === "retire") {
3261
3871
  for (let i = 0;i < args2.length; i++) {
3262
3872
  const arg = args2[i];
@@ -3280,9 +3890,9 @@ async function main(args = process.argv.slice(2), options = {}) {
3280
3890
  if (allDead && name2)
3281
3891
  throw new Error(usage2);
3282
3892
  } else if (args2.length !== 0) {
3283
- throw new Error(usage2);
3893
+ jobsPage = parseWorkJobsArgs(args2, usage2);
3284
3894
  }
3285
- const client2 = await GajaewayClient.connectSocket(parsed.socket);
3895
+ const client2 = await connectGatewaySocket(parsed.socket);
3286
3896
  try {
3287
3897
  if (command === "retire") {
3288
3898
  const result = await client2.request("work.retire", {
@@ -3297,13 +3907,18 @@ async function main(args = process.argv.slice(2), options = {}) {
3297
3907
  console.log(result.retired ? `retired: ${result.sessionKey} session=${result.sessionId} closed=${result.closed}${forced}` : `not retired: ${result.reason}`);
3298
3908
  }
3299
3909
  } else {
3300
- const result = await client2.request("work.jobs");
3910
+ const result = await client2.request("work.jobs", jobsPage);
3301
3911
  for (const job of result.jobs) {
3302
3912
  const name3 = job.lane_key.startsWith("work-") ? job.lane_key.slice("work-".length) : job.lane_key;
3303
3913
  const state = job.quarantined ? `HELD: quarantined reason=${job.reason} historical_state=${job.state}` : job.state;
3304
3914
  const head = job.last_commit ? `${job.last_commit.sha.slice(0, 7)}@${job.last_commit.committed_at} ${JSON.stringify(job.last_commit.subject)}` : "-";
3305
3915
  console.log(`${name3} ${state} session=${job.session_id || "-"} accepted=${job.accepted_at || "-"} op=${job.attempt?.op_ref || "-"} last=${job.last_activity_at || "-"} head=${head} ${job.worktree_path}${job.reports && (job.reports.pending !== 0 || job.reports.claimed !== 0 || job.reports.held !== 0 || job.reports.undeliverable !== 0) ? ` reports=p:${job.reports.pending} c:${job.reports.claimed} h:${job.reports.held} u:${job.reports.undeliverable}` : ""}`);
3306
3916
  }
3917
+ const shownEnd = result.offset + result.jobs.length;
3918
+ if (result.jobs.length === 0)
3919
+ console.error(`jobs: none at offset ${result.offset} of ${result.total}`);
3920
+ else if (result.offset > 0 || shownEnd < result.total)
3921
+ console.error(`jobs ${result.offset + 1}-${shownEnd} of ${result.total}${shownEnd < result.total ? ` (next: --offset ${shownEnd})` : ""}`);
3307
3922
  }
3308
3923
  } finally {
3309
3924
  await client2.close();
@@ -3343,7 +3958,7 @@ async function main(args = process.argv.slice(2), options = {}) {
3343
3958
  const text = textParts.join(" ").trim();
3344
3959
  if (!name || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name) || !text)
3345
3960
  throw new Error(usage2);
3346
- const client = await GajaewayClient.connectSocket(parsed.socket, {
3961
+ const client = await connectGatewaySocket(parsed.socket, {
3347
3962
  requestTimeoutMs: command === "run" ? 3600000 : 120000
3348
3963
  });
3349
3964
  try {
@@ -3431,9 +4046,9 @@ export {
3431
4046
  parseArgs,
3432
4047
  parseServicesArgs,
3433
4048
  renderCycle,
3434
- restoreDatabase,
3435
- restoreTargetPath,
4049
+ renderDeliveries,
4050
+ renderSlo,
4051
+ sloExitCode,
3436
4052
  socketPath,
3437
- usageFor,
3438
- verifyBackupIntegrity
4053
+ usageFor
3439
4054
  };