@gajae-gateway/cli 0.1.1 → 0.1.2

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,9 +2,10 @@
2
2
  // @bun
3
3
 
4
4
  // src/main.ts
5
- import { copyFile, stat } from "fs/promises";
6
- import { isAbsolute, join as join2 } from "path";
7
- import { LOOPBACK_ORIGIN, originKey as originKey2, parseOriginKey } from "@gajae-gateway/protocol";
5
+ import { Database } from "bun:sqlite";
6
+ import { copyFile, lstat, readFile as readFile3, stat as stat2 } from "fs/promises";
7
+ import { isAbsolute, join as join3 } from "path";
8
+ import { LOOPBACK_ORIGIN, originKey as originKey2 } from "@gajae-gateway/protocol";
8
9
  import { GajaewayClient } from "@gajae-gateway/sdk";
9
10
 
10
11
  // src/list.ts
@@ -126,6 +127,11 @@ function renderList(columns, rows, options, envelope) {
126
127
  return [JSON.stringify({ ...envelope.result, [envelope.key]: projected })];
127
128
  }
128
129
 
130
+ // src/restart-stack.ts
131
+ import { randomUUID } from "crypto";
132
+ import { mkdir as mkdir2, readFile as readFile2, rename, stat, writeFile as writeFile2 } from "fs/promises";
133
+ import { join as join2 } from "path";
134
+
129
135
  // src/services.ts
130
136
  import { chmod, mkdir, readFile, writeFile } from "fs/promises";
131
137
  import { homedir } from "os";
@@ -134,22 +140,34 @@ import { parseRuntimeConfig, RuntimeConfigError } from "@gajae-gateway/protocol"
134
140
  var CONFIG_SCHEMA_VERSION = 1;
135
141
  var FALLBACK_PATH_DIRS = ["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
136
142
  var LOGIN_PATH_COMMAND = 'printf %s "$PATH"';
143
+ var GATEWAY_UNIT = "gajaeway-gateway.service";
137
144
  var SERVICE_SPECS = [
138
- { id: "gateway", label: "dev.gajaeway.gateway", binary: "gajaeway-gateway", args: ["daemon"] },
145
+ {
146
+ id: "gateway",
147
+ label: "dev.gajaeway.gateway",
148
+ binary: "gajaeway-gateway",
149
+ args: ["daemon"],
150
+ dependsOnGateway: false
151
+ },
139
152
  {
140
153
  id: "adapter-discord",
141
154
  label: "dev.gajaeway.adapter-discord",
142
155
  binary: "gajaeway-discord",
143
- args: []
156
+ args: [],
157
+ dependsOnGateway: true
144
158
  },
145
159
  {
146
160
  id: "adapter-slack",
147
161
  label: "dev.gajaeway.adapter-slack",
148
162
  binary: "gajaeway-slack",
149
- args: []
163
+ args: [],
164
+ dependsOnGateway: true
150
165
  },
151
- { id: "admin", label: "dev.gajaeway.admin", binary: "gajaeway-admin", args: ["serve"] }
166
+ { id: "admin", label: "dev.gajaeway.admin", binary: "gajaeway-admin", args: ["serve"], dependsOnGateway: true }
152
167
  ];
168
+ function serviceSpecs() {
169
+ return SERVICE_SPECS;
170
+ }
153
171
  function homeForEnvironment(env) {
154
172
  return env.GAJAEWAY_HOME || join(env.HOME || homedir(), ".gajaeway");
155
173
  }
@@ -237,6 +255,64 @@ function renderLaunchAgent(spec, binDir, home, path) {
237
255
  </dict></plist>
238
256
  `;
239
257
  }
258
+ function unitEnvironment(name, value) {
259
+ return `Environment="${name}=${value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"")}"`;
260
+ }
261
+ function systemdUnitName(spec) {
262
+ return `gajaeway-${spec.id}.service`;
263
+ }
264
+ function renderSystemdUnit(spec, binDir, home, path) {
265
+ const execStart = [join(binDir, spec.binary), ...spec.args].join(" ");
266
+ const unit = [
267
+ "[Unit]",
268
+ `Description=gajaeway ${spec.id}`,
269
+ ...spec.dependsOnGateway ? [`BindsTo=${GATEWAY_UNIT}`, `After=${GATEWAY_UNIT}`, `PartOf=${GATEWAY_UNIT}`] : [],
270
+ "",
271
+ "[Service]",
272
+ `ExecStart=${execStart}`,
273
+ `WorkingDirectory=${binDir}`,
274
+ unitEnvironment("GAJAEWAY_HOME", home),
275
+ unitEnvironment("PATH", path),
276
+ "Restart=always",
277
+ "RestartSec=2",
278
+ ...spec.dependsOnGateway ? [] : ["KillMode=process", "TimeoutStopSec=30s"],
279
+ "",
280
+ "[Install]",
281
+ `WantedBy=${spec.dependsOnGateway ? GATEWAY_UNIT : "default.target"}`,
282
+ ""
283
+ ];
284
+ return unit.join(`
285
+ `);
286
+ }
287
+ function systemdUnitDir(env, userHome) {
288
+ const base = env.XDG_CONFIG_HOME || join(userHome, ".config");
289
+ return join(base, "systemd", "user");
290
+ }
291
+ function restartStackCommands(platform, uid) {
292
+ if (platform === "linux")
293
+ return [["systemctl", "--user", "restart", GATEWAY_UNIT]];
294
+ if (platform === "darwin") {
295
+ const target = uid ?? process.getuid?.() ?? 0;
296
+ return restartOrder().map((spec) => ["launchctl", "kickstart", "-k", `gui/${target}/${spec.label}`]);
297
+ }
298
+ throw new Error(`unsupported service platform: ${platform}`);
299
+ }
300
+ function restartOrder() {
301
+ return [
302
+ ...SERVICE_SPECS.filter((spec) => !spec.dependsOnGateway),
303
+ ...SERVICE_SPECS.filter((spec) => spec.dependsOnGateway)
304
+ ];
305
+ }
306
+ function requirePlatform(platform) {
307
+ if (platform !== "darwin" && platform !== "linux")
308
+ throw new Error(`unsupported service platform: ${platform}`);
309
+ return platform;
310
+ }
311
+ function currentPlatform() {
312
+ if (process.platform === "darwin" || process.platform === "linux")
313
+ return process.platform;
314
+ throw new Error(`unsupported service platform: ${process.platform}`);
315
+ }
240
316
  async function readRuntime(home) {
241
317
  const configPath = join(home, "config.json");
242
318
  let raw;
@@ -267,10 +343,11 @@ async function installServices(options) {
267
343
  if (options.binDir.length === 0)
268
344
  throw new Error("services requires a non-empty --bin-dir DIR");
269
345
  const env = options.env ?? process.env;
346
+ const platform = options.platform === undefined ? currentPlatform() : requirePlatform(options.platform);
270
347
  const userHome = env.HOME || homedir();
271
348
  const home = homeForEnvironment(env);
272
349
  const binDir = expandHome(options.binDir, userHome);
273
- const launchAgentsDir = expandHome(options.launchAgentsDir ?? join(userHome, "Library", "LaunchAgents"), userHome);
350
+ const targetDir = platform === "darwin" ? expandHome(options.launchAgentsDir ?? join(userHome, "Library", "LaunchAgents"), userHome) : expandHome(options.unitDir ?? systemdUnitDir(env, userHome), userHome);
274
351
  const runtime = await readRuntime(home);
275
352
  const path = await resolvePath({
276
353
  binDir,
@@ -279,24 +356,257 @@ async function installServices(options) {
279
356
  env,
280
357
  loginPathRunner: options.loginPathRunner
281
358
  });
282
- await mkdir(launchAgentsDir, { recursive: true, mode: 448 });
283
- const plists = SERVICE_SPECS.map((spec) => ({
284
- path: join(launchAgentsDir, `dev.gajaeway.${spec.id}.plist`),
359
+ await mkdir(targetDir, { recursive: true, mode: 448 });
360
+ const definitions = SERVICE_SPECS.map((spec) => platform === "darwin" ? {
361
+ path: join(targetDir, `dev.gajaeway.${spec.id}.plist`),
285
362
  contents: renderLaunchAgent(spec, binDir, home, path)
286
- }));
363
+ } : { path: join(targetDir, systemdUnitName(spec)), contents: renderSystemdUnit(spec, binDir, home, path) });
287
364
  const write = options.writeFile;
288
- for (const plist of plists) {
365
+ for (const definition of definitions) {
289
366
  if (write)
290
- await write(plist.path, plist.contents);
367
+ await write(definition.path, definition.contents);
291
368
  else {
292
- await writeFile(plist.path, plist.contents, { encoding: "utf8", mode: 384 });
293
- await chmod(plist.path, 384);
369
+ await writeFile(definition.path, definition.contents, { encoding: "utf8", mode: 384 });
370
+ await chmod(definition.path, 384);
294
371
  }
295
372
  }
296
- return plists.map((plist) => plist.path);
373
+ return definitions.map((definition) => definition.path);
297
374
  }
298
375
  function serviceUsage() {
299
- return "usage: gajaeway services install|repair --bin-dir DIR [--launch-agents-dir DIR]";
376
+ return "usage: gajaeway services install|repair --bin-dir DIR [--launch-agents-dir DIR] [--unit-dir DIR] [--platform darwin|linux]";
377
+ }
378
+
379
+ // src/restart-stack.ts
380
+ var RECEIPT_FILE = "restart-stack.json";
381
+ var VERIFY_TIMEOUT_MS = 90000;
382
+ var POLL_MS = 1000;
383
+ function restartReceiptPath(home) {
384
+ return join2(home, RECEIPT_FILE);
385
+ }
386
+ async function readRestartReceipt(home) {
387
+ let raw;
388
+ try {
389
+ raw = await readFile2(restartReceiptPath(home), "utf8");
390
+ } catch (error) {
391
+ if (error.code === "ENOENT")
392
+ return;
393
+ throw error;
394
+ }
395
+ return JSON.parse(raw);
396
+ }
397
+ async function writeReceipt(home, receipt) {
398
+ await mkdir2(home, { recursive: true });
399
+ const path = restartReceiptPath(home);
400
+ const temporary = `${path}.${process.pid}.tmp`;
401
+ await writeFile2(temporary, `${JSON.stringify(receipt, null, 2)}
402
+ `, { mode: 384 });
403
+ await rename(temporary, path);
404
+ }
405
+ function newReceipt(id, platform, uid, now) {
406
+ const commands = restartStackCommands(platform, uid);
407
+ return {
408
+ id,
409
+ state: "queued",
410
+ platform,
411
+ requestedAt: new Date(now).toISOString(),
412
+ steps: restartOrder().map((spec, index) => ({
413
+ label: spec.label,
414
+ ...platform === "darwin" ? { command: commands[index] } : index === 0 ? { command: commands[0] } : {},
415
+ result: "pending"
416
+ }))
417
+ };
418
+ }
419
+ async function defaultCommandRunner(command) {
420
+ const child = Bun.spawn([...command], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
421
+ return await child.exited;
422
+ }
423
+ async function capture(command) {
424
+ const child = Bun.spawn([...command], {
425
+ stdin: "ignore",
426
+ stdout: "pipe",
427
+ stderr: "ignore",
428
+ env: { ...process.env, LC_ALL: "C" }
429
+ });
430
+ const output = await new Response(child.stdout).text();
431
+ return await child.exited === 0 ? output : undefined;
432
+ }
433
+ function defaultServiceProbe(platform) {
434
+ return async (label) => {
435
+ const spec = serviceSpecs().find((candidate) => candidate.label === label);
436
+ if (spec === undefined)
437
+ throw new Error(`unknown service label: ${label}`);
438
+ let pid;
439
+ if (platform === "darwin") {
440
+ const listed = await capture(["launchctl", "list", label]);
441
+ const match = listed?.match(/"PID"\s*=\s*(\d+);/);
442
+ pid = match ? Number(match[1]) : undefined;
443
+ } else {
444
+ const shown = await capture(["systemctl", "--user", "show", "-p", "MainPID", "--value", systemdUnitName(spec)]);
445
+ pid = shown === undefined ? undefined : Number(shown.trim());
446
+ }
447
+ if (pid === undefined || !Number.isInteger(pid) || pid <= 0)
448
+ return;
449
+ const lstart = await capture(["ps", "-o", "lstart=", "-p", String(pid)]);
450
+ const args = await capture(["ps", "-ww", "-o", "args=", "-p", String(pid)]);
451
+ if (lstart === undefined || args === undefined)
452
+ return;
453
+ const startedAt = Date.parse(lstart.trim());
454
+ if (!Number.isFinite(startedAt))
455
+ return;
456
+ const suffix = spec.args.length > 0 ? ` ${spec.args.join(" ")}` : "";
457
+ const line = args.trim();
458
+ const binary = suffix && line.endsWith(suffix) ? line.slice(0, -suffix.length) : line;
459
+ return { pid, startedAt, binary };
460
+ };
461
+ }
462
+ async function defaultBinaryModifiedAt(path) {
463
+ return (await stat(path)).mtimeMs;
464
+ }
465
+ function floorSecond(ms) {
466
+ return Math.floor(ms / 1000) * 1000;
467
+ }
468
+ async function runRestartStack(options) {
469
+ const platform = options.platform ?? currentPlatform();
470
+ const runner = options.runner ?? defaultCommandRunner;
471
+ const probe = options.probe ?? defaultServiceProbe(platform);
472
+ const modifiedAt = options.binaryModifiedAt ?? defaultBinaryModifiedAt;
473
+ const now = options.now ?? Date.now;
474
+ const sleep = options.sleep ?? ((ms) => Bun.sleep(ms));
475
+ const timeoutMs = options.verifyTimeoutMs ?? VERIFY_TIMEOUT_MS;
476
+ const pollMs = options.pollMs ?? POLL_MS;
477
+ const existing = await readRestartReceipt(options.home);
478
+ const receipt = existing?.id === options.id && existing.state === "queued" ? existing : newReceipt(options.id, platform, options.uid, now());
479
+ receipt.state = "running";
480
+ receipt.supervisorPid = process.pid;
481
+ await writeReceipt(options.home, receipt);
482
+ let issuedAt = now();
483
+ for (const step of receipt.steps) {
484
+ if (receipt.state !== "running") {
485
+ step.result = "skipped";
486
+ continue;
487
+ }
488
+ try {
489
+ if (step.command !== undefined) {
490
+ issuedAt = now();
491
+ const status = await runner(step.command);
492
+ step.exitStatus = status;
493
+ if (status !== 0) {
494
+ step.result = "failed";
495
+ step.detail = `${step.command.join(" ")} exited with status ${status}`;
496
+ }
497
+ }
498
+ if (step.result === "pending")
499
+ await verifyStep(step, issuedAt);
500
+ } catch (error) {
501
+ step.result = "failed";
502
+ step.detail = error instanceof Error ? error.message : String(error);
503
+ }
504
+ if (step.result !== "ok")
505
+ receipt.state = step.result === "stale" ? "stale" : "failed";
506
+ await writeReceipt(options.home, receipt);
507
+ }
508
+ if (receipt.state === "running")
509
+ receipt.state = "ok";
510
+ receipt.finishedAt = new Date(now()).toISOString();
511
+ await writeReceipt(options.home, receipt);
512
+ return receipt;
513
+ async function verifyStep(step, since) {
514
+ const deadline = now() + timeoutMs;
515
+ for (;; ) {
516
+ const running = await probe(step.label);
517
+ let binaryModifiedAt = 0;
518
+ if (running !== undefined) {
519
+ binaryModifiedAt = await modifiedAt(running.binary);
520
+ step.pid = running.pid;
521
+ step.processStartedAt = new Date(running.startedAt).toISOString();
522
+ step.binary = running.binary;
523
+ step.binaryModifiedAt = new Date(binaryModifiedAt).toISOString();
524
+ if (running.startedAt >= floorSecond(Math.max(binaryModifiedAt, since))) {
525
+ step.result = "ok";
526
+ delete step.detail;
527
+ return;
528
+ }
529
+ }
530
+ if (now() >= deadline) {
531
+ if (running === undefined) {
532
+ step.result = "failed";
533
+ step.detail = "no running process after restart";
534
+ } else {
535
+ step.result = "stale";
536
+ step.detail = running.startedAt < binaryModifiedAt ? "process started before the deployed binary was modified" : "process was not restarted by this sequence";
537
+ }
538
+ return;
539
+ }
540
+ await sleep(pollMs);
541
+ }
542
+ }
543
+ }
544
+ function cliCommand() {
545
+ return Bun.main.startsWith("/$bunfs/") ? [process.execPath] : [process.execPath, Bun.main];
546
+ }
547
+ function alive(pid) {
548
+ if (pid === undefined)
549
+ return false;
550
+ try {
551
+ process.kill(pid, 0);
552
+ return true;
553
+ } catch {
554
+ return false;
555
+ }
556
+ }
557
+ async function launchRestartStack(options) {
558
+ const platform = options.platform ?? currentPlatform();
559
+ const existing = await readRestartReceipt(options.home);
560
+ if (existing?.state === "running" && alive(existing.supervisorPid))
561
+ throw new Error(`restart-stack ${existing.id} is still running (supervisor pid ${existing.supervisorPid})`);
562
+ const receipt = newReceipt(randomUUID(), platform, options.uid, Date.now());
563
+ await writeReceipt(options.home, receipt);
564
+ const worker = [...options.worker ?? [...cliCommand(), "ops", "restart-stack"], "--run", receipt.id];
565
+ const child = Bun.spawn(supervisorArgv(platform, worker, options.home, receipt.id), {
566
+ detached: true,
567
+ stdin: "ignore",
568
+ stdout: "ignore",
569
+ stderr: "ignore",
570
+ env: { ...options.env ?? process.env, GAJAEWAY_HOME: options.home }
571
+ });
572
+ child.unref();
573
+ return { receipt, supervisorPid: child.pid };
574
+ }
575
+ function supervisorArgv(platform, worker, home, id) {
576
+ if (platform === "darwin")
577
+ return [...worker];
578
+ return [
579
+ "systemd-run",
580
+ "--user",
581
+ "--collect",
582
+ "--quiet",
583
+ `--unit=gajaeway-restart-stack-${id}`,
584
+ `--setenv=GAJAEWAY_HOME=${home}`,
585
+ "--",
586
+ ...worker
587
+ ];
588
+ }
589
+ function effectiveRestartState(receipt) {
590
+ if (receipt.state === "running" && !alive(receipt.supervisorPid))
591
+ return "interrupted";
592
+ return receipt.state;
593
+ }
594
+ function renderRestartReceipt(receipt) {
595
+ const lines = [
596
+ `restart-stack ${receipt.id}: ${effectiveRestartState(receipt)}`,
597
+ `requestedAt: ${receipt.requestedAt}`,
598
+ `finishedAt: ${receipt.finishedAt ?? "-"}`
599
+ ];
600
+ for (const step of receipt.steps) {
601
+ const facts = [
602
+ step.pid === undefined ? undefined : `pid=${step.pid}`,
603
+ step.processStartedAt === undefined ? undefined : `started=${step.processStartedAt}`,
604
+ step.binaryModifiedAt === undefined ? undefined : `binary=${step.binaryModifiedAt}`,
605
+ step.detail
606
+ ].filter((fact) => fact !== undefined);
607
+ lines.push(`${step.label}: ${step.result}${facts.length > 0 ? ` ${facts.join(" ")}` : ""}`);
608
+ }
609
+ return lines;
300
610
  }
301
611
 
302
612
  // src/main.ts
@@ -315,7 +625,7 @@ var COMMANDS = [
315
625
  "work",
316
626
  "services"
317
627
  ];
318
- 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 ...|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 <name>|work jobs|ops backup <path>|ops cycle [--json]|ops integrity|ops restore <backupPath>|services install|repair --bin-dir DIR [--launch-agents-dir DIR] (work run waits for a response; caller timeout does not end the attempt)";
628
+ 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] <text>|work status <name>|work steer <name> <text>|work retire <name>|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] (work run waits for a response; caller timeout does not end the attempt)";
319
629
  var USAGE_EXIT_CODE = 2;
320
630
  function usageFor(command) {
321
631
  return command !== undefined && COMMANDS.includes(command) ? undefined : CLI_USAGE;
@@ -340,22 +650,171 @@ function parseServicesArgs(args) {
340
650
  throw new Error(serviceUsage());
341
651
  let binDir;
342
652
  let launchAgentsDir;
653
+ let unitDir;
654
+ let platform;
343
655
  for (let i = 1;i < args.length; i++) {
344
656
  const flag = args[i];
345
- if (flag === "--bin-dir" || flag === "--launch-agents-dir") {
657
+ if (flag === "--bin-dir" || flag === "--launch-agents-dir" || flag === "--unit-dir") {
346
658
  const value = args[++i];
347
659
  if (value === undefined || value.length === 0 || value.startsWith("--"))
348
660
  throw new Error(`${flag} expects a non-empty DIR`);
349
661
  if (flag === "--bin-dir")
350
662
  binDir = value;
663
+ else if (flag === "--unit-dir")
664
+ unitDir = value;
351
665
  else
352
666
  launchAgentsDir = value;
667
+ } else if (flag === "--platform") {
668
+ const value = args[++i];
669
+ if (value !== "darwin" && value !== "linux")
670
+ throw new Error("--platform expects darwin or linux");
671
+ platform = value;
353
672
  } else
354
673
  throw new Error(`unknown option: ${flag}`);
355
674
  }
356
675
  if (binDir === undefined)
357
676
  throw new Error("services requires --bin-dir DIR");
358
- return { action, binDir, ...launchAgentsDir === undefined ? {} : { launchAgentsDir } };
677
+ return {
678
+ action,
679
+ binDir,
680
+ ...launchAgentsDir === undefined ? {} : { launchAgentsDir },
681
+ ...unitDir === undefined ? {} : { unitDir },
682
+ ...platform === undefined ? {} : { platform }
683
+ };
684
+ }
685
+ var MONITOR_UPDATE_USAGE = "usage: gajaeway monitors update <id> (--json '<partial MonitorSpec JSON>'|--schedule '<cron>' [--enabled true|false]|--enabled true|false [--schedule '<cron>'])";
686
+ function parseMonitorUpdateArgs(args) {
687
+ const [monitorId, ...options] = args;
688
+ if (!monitorId || monitorId.startsWith("--") || options.length === 0)
689
+ throw new Error(MONITOR_UPDATE_USAGE);
690
+ let jsonPatch;
691
+ let schedule;
692
+ let enabled;
693
+ for (let i = 0;i < options.length; i++) {
694
+ const option = options[i];
695
+ if (option === "--json") {
696
+ const value = options[++i];
697
+ if (jsonPatch !== undefined || value === undefined || value.startsWith("--"))
698
+ throw new Error(MONITOR_UPDATE_USAGE);
699
+ let parsed;
700
+ try {
701
+ parsed = JSON.parse(value);
702
+ } catch {
703
+ throw new Error(MONITOR_UPDATE_USAGE);
704
+ }
705
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
706
+ throw new Error(MONITOR_UPDATE_USAGE);
707
+ jsonPatch = parsed;
708
+ } else if (option === "--schedule") {
709
+ const value = options[++i];
710
+ if (schedule !== undefined || !value || value.startsWith("--"))
711
+ throw new Error(MONITOR_UPDATE_USAGE);
712
+ schedule = value;
713
+ } else if (option === "--enabled") {
714
+ const value = options[++i];
715
+ if (enabled !== undefined || value !== "true" && value !== "false")
716
+ throw new Error(MONITOR_UPDATE_USAGE);
717
+ enabled = value === "true";
718
+ } else
719
+ throw new Error(MONITOR_UPDATE_USAGE);
720
+ }
721
+ if (jsonPatch !== undefined && (schedule !== undefined || enabled !== undefined))
722
+ throw new Error(MONITOR_UPDATE_USAGE);
723
+ if (jsonPatch !== undefined)
724
+ return { monitorId, params: { ...jsonPatch, monitorId } };
725
+ return {
726
+ monitorId,
727
+ params: {
728
+ monitorId,
729
+ ...schedule === undefined ? {} : { schedule },
730
+ ...enabled === undefined ? {} : { enabled }
731
+ }
732
+ };
733
+ }
734
+ var MONITOR_TEST_USAGE = "usage: gajaeway monitors test <id> [--type T] [--payload J] [--wait[=SECONDS]]";
735
+ var MONITOR_WAIT_ERROR = `${MONITOR_TEST_USAGE}
736
+ --wait expects a finite non-negative integer up to 86400`;
737
+ function parseMonitorTestArgs(args) {
738
+ const [monitorId, ...flags] = args;
739
+ if (!monitorId || monitorId.startsWith("--"))
740
+ throw new Error(MONITOR_TEST_USAGE);
741
+ let eventType;
742
+ let payload = {};
743
+ let waitSeconds;
744
+ const seen = new Set;
745
+ for (let i = 0;i < flags.length; i++) {
746
+ const flag = flags[i];
747
+ if (flag === "--type" || flag === "--payload") {
748
+ if (seen.has(flag))
749
+ throw new Error(`${MONITOR_TEST_USAGE}
750
+ duplicate ${flag}`);
751
+ seen.add(flag);
752
+ const value = flags[++i];
753
+ if (value === undefined || value.length === 0 || value.startsWith("--"))
754
+ throw new Error(`${MONITOR_TEST_USAGE}
755
+ ${flag} requires a value`);
756
+ if (flag === "--type")
757
+ eventType = value;
758
+ else
759
+ payload = JSON.parse(value);
760
+ } else if (flag === "--wait" || flag.startsWith("--wait=")) {
761
+ if (seen.has("--wait"))
762
+ throw new Error(`${MONITOR_TEST_USAGE}
763
+ duplicate --wait`);
764
+ seen.add("--wait");
765
+ const value = flag === "--wait" ? undefined : flag.slice("--wait=".length);
766
+ if (value === undefined)
767
+ waitSeconds = 30;
768
+ else {
769
+ if (!/^\d+$/.test(value))
770
+ throw new Error(MONITOR_WAIT_ERROR);
771
+ const seconds = Number(value);
772
+ if (!Number.isFinite(seconds) || !Number.isInteger(seconds) || seconds > 86400)
773
+ throw new Error(MONITOR_WAIT_ERROR);
774
+ waitSeconds = seconds;
775
+ }
776
+ } else
777
+ throw new Error(`${MONITOR_TEST_USAGE}
778
+ unknown argument: ${flag}`);
779
+ }
780
+ return {
781
+ monitorId,
782
+ ...eventType === undefined ? {} : { eventType },
783
+ payload,
784
+ ...waitSeconds === undefined ? {} : { waitSeconds }
785
+ };
786
+ }
787
+ var TERMINAL_MONITOR_STAGES = new Set(["delivered", "authored_no_delivery", "failed", "failed_no_retry"]);
788
+ function waitForMonitorStage(client, eventId, timeoutSeconds) {
789
+ return new Promise((resolve) => {
790
+ let stage = "admitted";
791
+ let settled = false;
792
+ let timer;
793
+ let unsubscribe;
794
+ const finish = () => {
795
+ if (settled)
796
+ return;
797
+ settled = true;
798
+ if (timer !== undefined)
799
+ clearTimeout(timer);
800
+ unsubscribe?.();
801
+ resolve(stage);
802
+ };
803
+ unsubscribe = client.on("monitor.event", (payload) => {
804
+ if (typeof payload !== "object" || payload === null)
805
+ return;
806
+ const event = payload;
807
+ if (event.eventId !== eventId || typeof event.stage !== "string")
808
+ return;
809
+ stage = event.stage;
810
+ if (TERMINAL_MONITOR_STAGES.has(stage))
811
+ finish();
812
+ });
813
+ if (settled)
814
+ unsubscribe();
815
+ else
816
+ timer = setTimeout(finish, timeoutSeconds * 1000);
817
+ });
359
818
  }
360
819
  function renderCycle(cycle) {
361
820
  const lines = [];
@@ -370,7 +829,14 @@ function renderCycle(cycle) {
370
829
  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 ?? "-"}`);
371
830
  lines.push(`deliveries: pending=${cycle.deliveries.pending} inflight=${cycle.deliveries.inflight} confirmed=${cycle.deliveries.confirmed} failed_ambiguous=${cycle.deliveries.failedAmbiguous} expired=${cycle.deliveries.expired}`);
372
831
  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)" : ""}`);
832
+ if (cycle.agentDisk) {
833
+ const { path, freeBytes, totalBytes } = cycle.agentDisk;
834
+ const gib = (bytes) => (bytes / 1024 ** 3).toFixed(1);
835
+ lines.push(freeBytes === null || totalBytes === null ? `agent_disk: ${path} unobservable` : `agent_disk: ${path} free=${gib(freeBytes)}GiB total=${gib(totalBytes)}GiB`);
836
+ }
373
837
  lines.push(`monitors: ${cycle.monitorEvents.length ? cycle.monitorEvents.map((m) => `${m.stage}=${m.count}`).join(" ") : "none"}`);
838
+ if (cycle.monitorAuthoringLost.length > 0)
839
+ lines.push(`monitor authoring lost: ${cycle.monitorAuthoringLost.map((m) => `${m.eventType}=${m.consecutive} (last ${m.lastFiredAt})`).join(" ")}`);
374
840
  if (cycle.sessions.length > 0) {
375
841
  lines.push("sessions:");
376
842
  lines.push("INDEX ORIGIN EPOCH SESSION PENDING UNSETTLED OLDEST");
@@ -385,11 +851,61 @@ function renderCycle(cycle) {
385
851
  function cycleExitCode(cycle) {
386
852
  return cycle.gates.length > 0 ? 1 : 0;
387
853
  }
854
+ function verifyBackupIntegrity(backupPath) {
855
+ let database;
856
+ try {
857
+ database = new Database(backupPath, { readonly: true });
858
+ } catch (error) {
859
+ throw new Error(`Backup is not readable: ${backupPath} (${error instanceof Error ? error.message : String(error)})`);
860
+ }
861
+ let detail;
862
+ let pageCount;
863
+ try {
864
+ pageCount = database.query("PRAGMA page_count").get()?.page_count ?? 0;
865
+ detail = database.query("PRAGMA integrity_check").get()?.integrity_check ?? "unknown";
866
+ } catch (error) {
867
+ throw new Error(`Backup is not a readable SQLite database: ${backupPath} (${error instanceof Error ? error.message : String(error)})`);
868
+ } finally {
869
+ database.close();
870
+ }
871
+ if (pageCount === 0)
872
+ throw new Error(`Backup is an empty SQLite database: ${backupPath}`);
873
+ if (detail !== "ok")
874
+ throw new Error(`Backup failed SQLite integrity_check: ${backupPath} (${detail})`);
875
+ }
876
+ async function restoreTargetPath(home = gatewayHome()) {
877
+ const configPath = join3(home, "config.json");
878
+ let raw;
879
+ try {
880
+ raw = await readFile3(configPath, "utf8");
881
+ } catch (error) {
882
+ const code = error.code;
883
+ const entryExists = await lstat(configPath).then(() => true, () => false);
884
+ if (code !== "ENOENT" || entryExists)
885
+ throw new Error(`Cannot read ${configPath}; refusing restore.`);
886
+ }
887
+ if (raw === undefined)
888
+ return join3(home, "gateway.db");
889
+ let parsed;
890
+ try {
891
+ parsed = JSON.parse(raw);
892
+ } catch {
893
+ throw new Error(`Cannot parse ${configPath}; refusing restore.`);
894
+ }
895
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
896
+ throw new Error(`Invalid configuration ${configPath}: config must be an object; refusing restore.`);
897
+ const dbPath = parsed.dbPath;
898
+ if (dbPath === undefined)
899
+ return join3(home, "gateway.db");
900
+ if (typeof dbPath !== "string" || dbPath.length === 0)
901
+ throw new Error(`Invalid configuration ${configPath}: dbPath must be a non-empty string; refusing restore.`);
902
+ return dbPath;
903
+ }
388
904
  async function restoreDatabase(socket, backupPath) {
389
905
  if (!isAbsolute(backupPath))
390
906
  throw new Error("ops restore requires an absolute backup path");
391
907
  try {
392
- await stat(socket);
908
+ await stat2(socket);
393
909
  throw new Error(`Refusing restore: gateway socket ${socket} exists. Stop the daemon before restoring.`);
394
910
  } catch (error) {
395
911
  if (error instanceof Error && error.message.startsWith("Refusing restore:"))
@@ -397,15 +913,8 @@ async function restoreDatabase(socket, backupPath) {
397
913
  if (error.code !== "ENOENT")
398
914
  throw new Error(`Cannot verify gateway socket ${socket}; refusing restore.`);
399
915
  }
400
- let header;
401
- try {
402
- header = new Uint8Array(await Bun.file(backupPath).slice(0, 16).arrayBuffer());
403
- } catch {
404
- throw new Error(`Backup is not readable: ${backupPath}`);
405
- }
406
- if (new TextDecoder().decode(header) !== "SQLite format 3\x00")
407
- throw new Error(`Backup is not a SQLite database: ${backupPath}`);
408
- const databasePath = join2(gatewayHome(), "gateway.db");
916
+ verifyBackupIntegrity(backupPath);
917
+ const databasePath = await restoreTargetPath();
409
918
  const preservedPath = `${databasePath}.pre-restore-${new Date().toISOString().replace(/[:.]/g, "-")}`;
410
919
  await copyFile(databasePath, preservedPath);
411
920
  await copyFile(backupPath, databasePath);
@@ -413,6 +922,7 @@ async function restoreDatabase(socket, backupPath) {
413
922
  console.log(`Copied current database to: ${preservedPath}`);
414
923
  console.log(`Restored backup to: ${databasePath}`);
415
924
  }
925
+ var CHAT_TURN_TIMEOUT_MS = 30 * 60000;
416
926
  async function chat(socket) {
417
927
  let client;
418
928
  try {
@@ -437,9 +947,9 @@ async function chat(socket) {
437
947
  await new Promise((resolve) => {
438
948
  const timer = setTimeout(() => {
439
949
  turnWaiters.delete(turnId);
440
- console.error("(turn timed out after 120s)");
950
+ console.error(`(turn timed out after ${CHAT_TURN_TIMEOUT_MS / 60000}m)`);
441
951
  resolve();
442
- }, 120000);
952
+ }, CHAT_TURN_TIMEOUT_MS);
443
953
  turnWaiters.set(turnId, () => {
444
954
  clearTimeout(timer);
445
955
  turnWaiters.delete(turnId);
@@ -549,10 +1059,51 @@ async function main(args = process.argv.slice(2), options = {}) {
549
1059
  await restoreDatabase(parsed.socket, path);
550
1060
  break;
551
1061
  }
1062
+ if (command === "restart-stack") {
1063
+ const usage2 = "usage: gajaeway ops restart-stack [--status]";
1064
+ const home = gatewayHome();
1065
+ const flag = parsed.rest[1];
1066
+ if (flag === "--run" && parsed.rest.length === 3 && parsed.rest[2]) {
1067
+ const receipt = await runRestartStack({
1068
+ ...options.restartStack?.run,
1069
+ home,
1070
+ id: parsed.rest[2]
1071
+ });
1072
+ for (const line of renderRestartReceipt(receipt))
1073
+ console.log(line);
1074
+ if (receipt.state !== "ok")
1075
+ process.exitCode = 1;
1076
+ } else if (flag === "--status" && parsed.rest.length === 2) {
1077
+ const receipt = await readRestartReceipt(home);
1078
+ if (receipt === undefined)
1079
+ throw new Error("no restart-stack receipt");
1080
+ for (const line of renderRestartReceipt(receipt))
1081
+ console.log(line);
1082
+ if (effectiveRestartState(receipt) !== "ok")
1083
+ process.exitCode = 1;
1084
+ } else if (parsed.rest.length === 1) {
1085
+ const { receipt, supervisorPid } = await launchRestartStack({ ...options.restartStack?.launch, home });
1086
+ console.log(`restart-stack ${receipt.id}: queued (supervisor pid ${supervisorPid})`);
1087
+ console.log("read the outcome with: gajaeway ops restart-stack --status");
1088
+ } else
1089
+ throw new Error(usage2);
1090
+ break;
1091
+ }
1092
+ let redeliverParams;
1093
+ if (command === "redeliver") {
1094
+ if (parsed.rest.length === 2 && path && path !== "--since")
1095
+ redeliverParams = { deliveryId: path };
1096
+ else if (parsed.rest.length === 3 && path === "--since" && parsed.rest[2] && Number.isFinite(Date.parse(parsed.rest[2])))
1097
+ redeliverParams = { since: parsed.rest[2] };
1098
+ else
1099
+ throw new Error("usage: gajaeway ops redeliver <deliveryId>|--since <iso>");
1100
+ }
552
1101
  const client = await GajaewayClient.connectSocket(parsed.socket);
553
1102
  try {
554
1103
  if (command === "backup" && path)
555
1104
  console.log(JSON.stringify(await client.request("ops.backup", { path })));
1105
+ else if (redeliverParams)
1106
+ console.log(JSON.stringify(await client.request("ops.redeliver", redeliverParams)));
556
1107
  else if (command === "integrity")
557
1108
  console.log(JSON.stringify(await client.request("ops.integrity")));
558
1109
  else if (command === "cycle") {
@@ -566,7 +1117,7 @@ async function main(args = process.argv.slice(2), options = {}) {
566
1117
  console.log(line);
567
1118
  process.exitCode = cycleExitCode(cycle);
568
1119
  } else
569
- throw new Error("usage: gajaeway ops backup <path>|cycle [--json]|integrity|restore <backupPath>");
1120
+ throw new Error("usage: gajaeway ops backup <path>|redeliver <deliveryId>|redeliver --since <iso>|cycle [--json]|integrity|restore <backupPath>|restart-stack [--status]");
570
1121
  } finally {
571
1122
  await client.close();
572
1123
  }
@@ -595,12 +1146,16 @@ async function main(args = process.argv.slice(2), options = {}) {
595
1146
  break;
596
1147
  }
597
1148
  case "monitors": {
598
- const listOptions = parsed.rest[0] === "list" ? parseListOptions(parsed.rest.slice(1), MONITOR_COLUMNS) : undefined;
1149
+ const [command, ...args2] = parsed.rest;
1150
+ const listOptions = command === "list" ? parseListOptions(args2, MONITOR_COLUMNS) : undefined;
1151
+ const update = command === "update" ? parseMonitorUpdateArgs(args2) : undefined;
1152
+ const monitorTest = command === "test" ? parseMonitorTestArgs(args2) : undefined;
599
1153
  const client = await GajaewayClient.connectSocket(parsed.socket);
600
1154
  try {
601
- const [command, ...args2] = parsed.rest;
602
1155
  if (command === "add" && args2[0] === "--json" && args2[1])
603
1156
  console.log(JSON.stringify(await client.request("monitor.add", JSON.parse(args2[1]))));
1157
+ else if (command === "update" && update)
1158
+ console.log(JSON.stringify(await client.request("monitor.update", update.params)));
604
1159
  else if (command === "list") {
605
1160
  const options2 = listOptions;
606
1161
  const result = await client.request("monitor.list");
@@ -613,22 +1168,20 @@ async function main(args = process.argv.slice(2), options = {}) {
613
1168
  console.log(JSON.stringify(await client.request("monitor.inspect", { monitorId: args2[0] })));
614
1169
  else if (command === "remove" && args2[0])
615
1170
  console.log(JSON.stringify(await client.request("monitor.remove", { monitorId: args2[0] })));
616
- else if (command === "test" && args2[0]) {
617
- let eventType;
618
- let payload = {};
619
- for (let i = 1;i < args2.length; i++) {
620
- if (args2[i] === "--type")
621
- eventType = args2[++i];
622
- else if (args2[i] === "--payload")
623
- payload = JSON.parse(args2[++i] ?? "");
1171
+ else if (command === "test" && monitorTest) {
1172
+ const result = await client.request("monitor.test", {
1173
+ monitorId: monitorTest.monitorId,
1174
+ ...monitorTest.eventType === undefined ? {} : { eventType: monitorTest.eventType },
1175
+ payload: monitorTest.payload
1176
+ });
1177
+ if (monitorTest.waitSeconds === undefined)
1178
+ console.log(JSON.stringify(result));
1179
+ else {
1180
+ const stage = await waitForMonitorStage(client, result.eventId, monitorTest.waitSeconds);
1181
+ console.log(JSON.stringify({ eventId: result.eventId, stage }));
624
1182
  }
625
- console.log(JSON.stringify(await client.request("monitor.test", {
626
- monitorId: args2[0],
627
- ...eventType ? { eventType } : {},
628
- payload
629
- })));
630
1183
  } else
631
- throw new Error(`usage: gajaeway monitors add --json '<MonitorSpec json>'|list [--json] [--fields ${columnNames(MONITOR_COLUMNS).join(",")}] [--limit N] [--offset N]|inspect <id>|remove <id>|test <id> [--type T] [--payload J]`);
1184
+ throw new Error(`usage: gajaeway monitors add --json '<MonitorSpec json>'|update <id> (--json '<partial MonitorSpec JSON>'|--schedule '<cron>' [--enabled true|false]|--enabled true|false [--schedule '<cron>'])|list [--json] [--fields ${columnNames(MONITOR_COLUMNS).join(",")}] [--limit N] [--offset N]|inspect <id>|remove <id>|test <id> [--type T] [--payload J] [--wait[=SECONDS]]`);
632
1185
  } finally {
633
1186
  await client.close();
634
1187
  }
@@ -636,7 +1189,7 @@ async function main(args = process.argv.slice(2), options = {}) {
636
1189
  }
637
1190
  case "work": {
638
1191
  const [command, ...args2] = parsed.rest;
639
- const usage2 = 'usage: gajaeway work run|start <name> [--cwd DIR] [--resume] [--model ID|--preset NAME] [--notify originKey (start only)] "<task text>"|status <name>|steer <name> <text>|retire <name>|jobs';
1192
+ const usage2 = 'usage: gajaeway work run|start <name> [--cwd DIR] [--resume] [--model ID|--preset NAME] "<task text>"|status <name>|steer <name> <text>|retire <name>|jobs';
640
1193
  if (command === "status" || command === "steer") {
641
1194
  const name2 = args2[0];
642
1195
  const text2 = args2.slice(1).join(" ").trim();
@@ -670,7 +1223,8 @@ async function main(args = process.argv.slice(2), options = {}) {
670
1223
  for (const job of result.jobs) {
671
1224
  const name2 = job.lane_key.startsWith("work-") ? job.lane_key.slice("work-".length) : job.lane_key;
672
1225
  const state = job.quarantined ? `HELD: quarantined reason=${job.reason} historical_state=${job.state}` : job.state;
673
- console.log(`${name2} ${state} session=${job.session_id || "-"} last=${job.last_activity_at || "-"} ${job.worktree_path}`);
1226
+ const head = job.last_commit ? `${job.last_commit.sha.slice(0, 7)}@${job.last_commit.committed_at} ${JSON.stringify(job.last_commit.subject)}` : "-";
1227
+ console.log(`${name2} ${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}` : ""}`);
674
1228
  }
675
1229
  }
676
1230
  } finally {
@@ -684,11 +1238,10 @@ async function main(args = process.argv.slice(2), options = {}) {
684
1238
  let cwd;
685
1239
  let resume = false;
686
1240
  let model;
687
- let notify;
688
1241
  const textParts = [];
689
1242
  for (let i = 1;i < args2.length; i++) {
690
1243
  const arg = args2[i];
691
- if (arg === "--cwd" || arg === "--model" || arg === "--preset" || arg === "--notify") {
1244
+ if (arg === "--cwd" || arg === "--model" || arg === "--preset") {
692
1245
  const value = args2[++i];
693
1246
  if (!value?.trim() || value.startsWith("--"))
694
1247
  throw new Error(usage2);
@@ -696,15 +1249,6 @@ async function main(args = process.argv.slice(2), options = {}) {
696
1249
  if (cwd !== undefined || !isAbsolute(value))
697
1250
  throw new Error(usage2);
698
1251
  cwd = value;
699
- } else if (arg === "--notify") {
700
- if (command !== "start" || notify !== undefined)
701
- throw new Error(usage2);
702
- try {
703
- notify = parseOriginKey(value);
704
- } catch {
705
- throw new Error(`${usage2}
706
- invalid --notify originKey`);
707
- }
708
1252
  } else {
709
1253
  if (model !== undefined)
710
1254
  throw new Error(`${usage2}
@@ -725,13 +1269,14 @@ invalid --notify originKey`);
725
1269
  requestTimeoutMs: command === "run" ? 3600000 : 120000
726
1270
  });
727
1271
  try {
1272
+ const callerSessionId = process.env.GJC_SESSION_ID?.trim();
728
1273
  const params = {
729
1274
  name,
730
1275
  text,
731
1276
  ...cwd ? { cwd } : {},
732
1277
  ...resume ? { resume: true } : {},
733
1278
  ...model === undefined ? {} : { model },
734
- ...notify === undefined ? {} : { notify }
1279
+ ...callerSessionId ? { callerSessionId } : {}
735
1280
  };
736
1281
  if (command === "start") {
737
1282
  const result = await client.request("work.start", params);
@@ -761,10 +1306,14 @@ job: ${result.jobId} state: ${result.state}`);
761
1306
  const written = await installServices({
762
1307
  binDir: service.binDir,
763
1308
  ...service.launchAgentsDir === undefined ? {} : { launchAgentsDir: service.launchAgentsDir },
1309
+ ...service.unitDir === undefined ? {} : { unitDir: service.unitDir },
1310
+ ...service.platform === undefined ? {} : { platform: service.platform },
764
1311
  env: process.env,
765
1312
  ...options.services
766
1313
  });
767
- console.log(`services ${service.action}: wrote ${written.length} LaunchAgent plists`);
1314
+ console.log(`services ${service.action}: wrote ${written.length} service definitions`);
1315
+ for (const definition of written)
1316
+ console.log(definition);
768
1317
  break;
769
1318
  }
770
1319
  default:
@@ -787,6 +1336,8 @@ export {
787
1336
  parseServicesArgs,
788
1337
  renderCycle,
789
1338
  restoreDatabase,
1339
+ restoreTargetPath,
790
1340
  socketPath,
791
- usageFor
1341
+ usageFor,
1342
+ verifyBackupIntegrity
792
1343
  };