@hasna/machines 0.0.27 → 0.0.29

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/index.js CHANGED
@@ -13427,6 +13427,615 @@ function listPorts(machineId) {
13427
13427
  listeners: parsePortOutput(result.stdout, format)
13428
13428
  };
13429
13429
  }
13430
+ // src/commands/runtime.ts
13431
+ import { spawnSync as spawnSync4 } from "child_process";
13432
+ import { setTimeout as sleep } from "timers/promises";
13433
+
13434
+ // node_modules/@hasna/events/dist/index.js
13435
+ import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
13436
+ import { existsSync as existsSync7 } from "fs";
13437
+ import { homedir as homedir4 } from "os";
13438
+ import { join as join6 } from "path";
13439
+ import { createHmac, timingSafeEqual } from "crypto";
13440
+ import { randomUUID } from "crypto";
13441
+ import { spawn } from "child_process";
13442
+ import { randomUUID as randomUUID2 } from "crypto";
13443
+ function getPathValue(input, path) {
13444
+ return path.split(".").reduce((value, part) => {
13445
+ if (value && typeof value === "object" && part in value) {
13446
+ return value[part];
13447
+ }
13448
+ return;
13449
+ }, input);
13450
+ }
13451
+ function wildcardToRegExp(pattern) {
13452
+ const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
13453
+ return new RegExp(`^${escaped}$`);
13454
+ }
13455
+ function matchString(value, matcher) {
13456
+ if (matcher === undefined)
13457
+ return true;
13458
+ if (value === undefined)
13459
+ return false;
13460
+ const matchers = Array.isArray(matcher) ? matcher : [matcher];
13461
+ return matchers.some((item) => wildcardToRegExp(item).test(value));
13462
+ }
13463
+ function matchRecord(input, matcher) {
13464
+ if (!matcher)
13465
+ return true;
13466
+ return Object.entries(matcher).every(([path, expected]) => {
13467
+ const actual = getPathValue(input, path);
13468
+ if (typeof expected === "string" || Array.isArray(expected)) {
13469
+ return matchString(actual === undefined ? undefined : String(actual), expected);
13470
+ }
13471
+ return actual === expected;
13472
+ });
13473
+ }
13474
+ function eventMatchesFilter(event, filter) {
13475
+ return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
13476
+ }
13477
+ function channelMatchesEvent(channel, event) {
13478
+ if (!channel.enabled)
13479
+ return false;
13480
+ if (!channel.filters || channel.filters.length === 0)
13481
+ return true;
13482
+ return channel.filters.some((filter) => eventMatchesFilter(event, filter));
13483
+ }
13484
+ var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
13485
+ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
13486
+ function getEventsDataDir(override) {
13487
+ return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join6(homedir4(), ".hasna", "events");
13488
+ }
13489
+
13490
+ class JsonEventsStore {
13491
+ dataDir;
13492
+ channelsPath;
13493
+ eventsPath;
13494
+ deliveriesPath;
13495
+ constructor(dataDir = getEventsDataDir()) {
13496
+ this.dataDir = dataDir;
13497
+ this.channelsPath = join6(dataDir, "channels.json");
13498
+ this.eventsPath = join6(dataDir, "events.json");
13499
+ this.deliveriesPath = join6(dataDir, "deliveries.json");
13500
+ }
13501
+ async init() {
13502
+ await mkdir(this.dataDir, { recursive: true, mode: 448 });
13503
+ await chmod(this.dataDir, 448).catch(() => {
13504
+ return;
13505
+ });
13506
+ await this.ensureArrayFile(this.channelsPath);
13507
+ await this.ensureArrayFile(this.eventsPath);
13508
+ await this.ensureArrayFile(this.deliveriesPath);
13509
+ }
13510
+ async addChannel(channel) {
13511
+ await this.init();
13512
+ const channels = await this.readJson(this.channelsPath, []);
13513
+ const index = channels.findIndex((item) => item.id === channel.id);
13514
+ if (index >= 0) {
13515
+ channels[index] = { ...channel, createdAt: channels[index].createdAt, updatedAt: new Date().toISOString() };
13516
+ } else {
13517
+ channels.push(channel);
13518
+ }
13519
+ await this.writeJson(this.channelsPath, channels);
13520
+ return index >= 0 ? channels[index] : channel;
13521
+ }
13522
+ async listChannels() {
13523
+ await this.init();
13524
+ return this.readJson(this.channelsPath, []);
13525
+ }
13526
+ async getChannel(id) {
13527
+ const channels = await this.listChannels();
13528
+ return channels.find((channel) => channel.id === id);
13529
+ }
13530
+ async removeChannel(id) {
13531
+ await this.init();
13532
+ const channels = await this.readJson(this.channelsPath, []);
13533
+ const next = channels.filter((channel) => channel.id !== id);
13534
+ await this.writeJson(this.channelsPath, next);
13535
+ return next.length !== channels.length;
13536
+ }
13537
+ async appendEvent(event) {
13538
+ await this.init();
13539
+ const events = await this.readJson(this.eventsPath, []);
13540
+ events.push(event);
13541
+ await this.writeJson(this.eventsPath, events);
13542
+ return event;
13543
+ }
13544
+ async listEvents() {
13545
+ await this.init();
13546
+ return this.readJson(this.eventsPath, []);
13547
+ }
13548
+ async findEventByIdentity(identity) {
13549
+ const events = await this.listEvents();
13550
+ return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
13551
+ }
13552
+ async appendDelivery(result) {
13553
+ await this.init();
13554
+ const deliveries = await this.readJson(this.deliveriesPath, []);
13555
+ deliveries.push(result);
13556
+ await this.writeJson(this.deliveriesPath, deliveries);
13557
+ return result;
13558
+ }
13559
+ async listDeliveries() {
13560
+ await this.init();
13561
+ return this.readJson(this.deliveriesPath, []);
13562
+ }
13563
+ async exportData() {
13564
+ return {
13565
+ channels: await this.listChannels(),
13566
+ events: await this.listEvents(),
13567
+ deliveries: await this.listDeliveries()
13568
+ };
13569
+ }
13570
+ async ensureArrayFile(path) {
13571
+ if (!existsSync7(path)) {
13572
+ await writeFile(path, `[]
13573
+ `, { encoding: "utf-8", mode: 384 });
13574
+ }
13575
+ await chmod(path, 384).catch(() => {
13576
+ return;
13577
+ });
13578
+ }
13579
+ async readJson(path, fallback) {
13580
+ try {
13581
+ const raw = await readFile(path, "utf-8");
13582
+ if (!raw.trim())
13583
+ return fallback;
13584
+ return JSON.parse(raw);
13585
+ } catch (error) {
13586
+ if (error.code === "ENOENT")
13587
+ return fallback;
13588
+ throw error;
13589
+ }
13590
+ }
13591
+ async writeJson(path, value) {
13592
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
13593
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
13594
+ `, { encoding: "utf-8", mode: 384 });
13595
+ await rename(tempPath, path);
13596
+ await chmod(path, 384).catch(() => {
13597
+ return;
13598
+ });
13599
+ }
13600
+ }
13601
+ var DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
13602
+ function buildSignatureBase(timestamp, body) {
13603
+ return `${timestamp}.${body}`;
13604
+ }
13605
+ function signPayload(secret, timestamp, body) {
13606
+ const digest = createHmac("sha256", secret).update(buildSignatureBase(timestamp, body)).digest("hex");
13607
+ return `sha256=${digest}`;
13608
+ }
13609
+ function now() {
13610
+ return new Date().toISOString();
13611
+ }
13612
+ function truncate(value, max = 4096) {
13613
+ return value.length > max ? `${value.slice(0, max)}...` : value;
13614
+ }
13615
+ function buildWebhookRequest(event, channel) {
13616
+ if (!channel.webhook)
13617
+ throw new Error(`Channel ${channel.id} has no webhook config`);
13618
+ const body = JSON.stringify(event);
13619
+ const timestamp = event.time;
13620
+ const headers = {
13621
+ "Content-Type": "application/json",
13622
+ "User-Agent": "@hasna/events",
13623
+ "X-Hasna-Event-Id": event.id,
13624
+ "X-Hasna-Event-Type": event.type,
13625
+ "X-Hasna-Timestamp": timestamp,
13626
+ ...channel.webhook.headers
13627
+ };
13628
+ if (channel.webhook.secret) {
13629
+ headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp, body);
13630
+ }
13631
+ return { body, headers };
13632
+ }
13633
+ async function dispatchWebhook2(event, channel, options = {}) {
13634
+ if (!channel.webhook)
13635
+ throw new Error(`Channel ${channel.id} has no webhook config`);
13636
+ const startedAt = now();
13637
+ const { body, headers } = buildWebhookRequest(event, channel);
13638
+ const controller = new AbortController;
13639
+ const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
13640
+ try {
13641
+ const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
13642
+ method: "POST",
13643
+ headers,
13644
+ body,
13645
+ signal: controller.signal
13646
+ });
13647
+ const responseBody = truncate(await response.text());
13648
+ return {
13649
+ attempt: 1,
13650
+ status: response.ok ? "success" : "failed",
13651
+ startedAt,
13652
+ completedAt: now(),
13653
+ responseStatus: response.status,
13654
+ responseBody,
13655
+ error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
13656
+ };
13657
+ } catch (error) {
13658
+ return {
13659
+ attempt: 1,
13660
+ status: "failed",
13661
+ startedAt,
13662
+ completedAt: now(),
13663
+ error: error instanceof Error ? error.message : String(error)
13664
+ };
13665
+ } finally {
13666
+ clearTimeout(timeout);
13667
+ }
13668
+ }
13669
+ async function dispatchCommand2(event, channel) {
13670
+ if (!channel.command)
13671
+ throw new Error(`Channel ${channel.id} has no command config`);
13672
+ const startedAt = now();
13673
+ const eventJson = JSON.stringify(event);
13674
+ const env = {
13675
+ ...process.env,
13676
+ ...channel.command.env,
13677
+ HASNA_CHANNEL_ID: channel.id,
13678
+ HASNA_EVENT_ID: event.id,
13679
+ HASNA_EVENT_TYPE: event.type,
13680
+ HASNA_EVENT_SOURCE: event.source,
13681
+ HASNA_EVENT_SUBJECT: event.subject ?? "",
13682
+ HASNA_EVENT_SEVERITY: event.severity,
13683
+ HASNA_EVENT_TIME: event.time,
13684
+ HASNA_EVENT_DEDUPE_KEY: event.dedupeKey ?? "",
13685
+ HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
13686
+ HASNA_EVENT_JSON: eventJson
13687
+ };
13688
+ return new Promise((resolve2) => {
13689
+ const child = spawn(channel.command.command, channel.command.args ?? [], {
13690
+ cwd: channel.command.cwd,
13691
+ env,
13692
+ stdio: ["pipe", "pipe", "pipe"]
13693
+ });
13694
+ let stdout = "";
13695
+ let stderr = "";
13696
+ const timeout = setTimeout(() => child.kill("SIGTERM"), channel.command.timeoutMs ?? 15000);
13697
+ child.stdin.end(eventJson);
13698
+ child.stdout.on("data", (chunk) => {
13699
+ stdout += chunk.toString();
13700
+ });
13701
+ child.stderr.on("data", (chunk) => {
13702
+ stderr += chunk.toString();
13703
+ });
13704
+ child.on("error", (error) => {
13705
+ clearTimeout(timeout);
13706
+ resolve2({
13707
+ attempt: 1,
13708
+ status: "failed",
13709
+ startedAt,
13710
+ completedAt: now(),
13711
+ stdout: truncate(stdout),
13712
+ stderr: truncate(stderr),
13713
+ error: error.message
13714
+ });
13715
+ });
13716
+ child.on("close", (code, signal) => {
13717
+ clearTimeout(timeout);
13718
+ const success = code === 0;
13719
+ resolve2({
13720
+ attempt: 1,
13721
+ status: success ? "success" : "failed",
13722
+ startedAt,
13723
+ completedAt: now(),
13724
+ stdout: truncate(stdout),
13725
+ stderr: truncate(stderr),
13726
+ error: success ? undefined : `Command exited with ${signal ? `signal ${signal}` : `code ${code}`}`
13727
+ });
13728
+ });
13729
+ });
13730
+ }
13731
+ async function dispatchChannel2(event, channel, options = {}) {
13732
+ if (channel.transport === "webhook")
13733
+ return dispatchWebhook2(event, channel, options);
13734
+ if (channel.transport === "command")
13735
+ return dispatchCommand2(event, channel);
13736
+ return {
13737
+ attempt: 1,
13738
+ status: "skipped",
13739
+ startedAt: now(),
13740
+ completedAt: now(),
13741
+ error: `Unsupported transport: ${channel.transport}`
13742
+ };
13743
+ }
13744
+ function createDeliveryResult(event, channel, attempts) {
13745
+ const status = attempts.some((attempt) => attempt.status === "success") ? "success" : attempts.every((attempt) => attempt.status === "skipped") ? "skipped" : "failed";
13746
+ return {
13747
+ id: randomUUID(),
13748
+ eventId: event.id,
13749
+ channelId: channel.id,
13750
+ transport: channel.transport,
13751
+ status,
13752
+ attempts,
13753
+ createdAt: attempts[0]?.startedAt ?? now(),
13754
+ completedAt: attempts.at(-1)?.completedAt ?? now()
13755
+ };
13756
+ }
13757
+ function createEvent(input) {
13758
+ return {
13759
+ id: input.id ?? randomUUID2(),
13760
+ source: input.source,
13761
+ type: input.type,
13762
+ time: normalizeTime(input.time),
13763
+ subject: input.subject,
13764
+ severity: input.severity ?? "info",
13765
+ data: input.data ?? {},
13766
+ message: input.message,
13767
+ dedupeKey: input.dedupeKey,
13768
+ schemaVersion: input.schemaVersion ?? "1.0",
13769
+ metadata: input.metadata ?? {}
13770
+ };
13771
+ }
13772
+
13773
+ class EventsClient {
13774
+ store;
13775
+ redactors;
13776
+ transportOptions;
13777
+ constructor(options = {}) {
13778
+ this.store = options.store ?? new JsonEventsStore(options.dataDir);
13779
+ this.redactors = options.redactors ?? [];
13780
+ this.transportOptions = { fetchImpl: options.fetchImpl };
13781
+ }
13782
+ async addChannel(input) {
13783
+ const timestamp = new Date().toISOString();
13784
+ return this.store.addChannel({
13785
+ ...input,
13786
+ createdAt: input.createdAt ?? timestamp,
13787
+ updatedAt: input.updatedAt ?? timestamp
13788
+ });
13789
+ }
13790
+ async listChannels() {
13791
+ return this.store.listChannels();
13792
+ }
13793
+ async removeChannel(id) {
13794
+ return this.store.removeChannel(id);
13795
+ }
13796
+ async emit(input, options = {}) {
13797
+ const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
13798
+ if (options.dedupe !== false) {
13799
+ const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
13800
+ if (existing) {
13801
+ return { event: existing, deliveries: [], deduped: true };
13802
+ }
13803
+ }
13804
+ await this.store.appendEvent(event);
13805
+ const deliveries = options.deliver === false ? [] : await this.deliver(event);
13806
+ return { event, deliveries, deduped: false };
13807
+ }
13808
+ async listEvents() {
13809
+ return this.store.listEvents();
13810
+ }
13811
+ async listDeliveries() {
13812
+ return this.store.listDeliveries();
13813
+ }
13814
+ async deliver(event) {
13815
+ const channels = await this.store.listChannels();
13816
+ const selected = channels.filter((channel) => channelMatchesEvent(channel, event));
13817
+ const deliveries = [];
13818
+ for (const channel of selected) {
13819
+ const eventForChannel = await this.applyRedaction(event, channel);
13820
+ const result = await this.deliverWithRetry(eventForChannel, channel);
13821
+ await this.store.appendDelivery(result);
13822
+ deliveries.push(result);
13823
+ }
13824
+ return deliveries;
13825
+ }
13826
+ async testChannel(id, input = {}) {
13827
+ const channel = await this.store.getChannel(id);
13828
+ if (!channel)
13829
+ throw new Error(`Channel not found: ${id}`);
13830
+ const event = createEvent({
13831
+ source: input.source ?? "hasna.events",
13832
+ type: input.type ?? "events.test",
13833
+ subject: input.subject ?? id,
13834
+ severity: input.severity ?? "info",
13835
+ data: input.data ?? { test: true },
13836
+ message: input.message ?? "Hasna events test delivery",
13837
+ dedupeKey: input.dedupeKey,
13838
+ schemaVersion: input.schemaVersion,
13839
+ metadata: input.metadata,
13840
+ time: input.time,
13841
+ id: input.id
13842
+ });
13843
+ const eventForChannel = await this.applyRedaction(event, channel);
13844
+ const result = await this.deliverWithRetry(eventForChannel, channel);
13845
+ await this.store.appendDelivery(result);
13846
+ return result;
13847
+ }
13848
+ async replay(options = {}) {
13849
+ const events = (await this.store.listEvents()).filter((event) => {
13850
+ if (options.eventId && event.id !== options.eventId)
13851
+ return false;
13852
+ if (options.source && event.source !== options.source)
13853
+ return false;
13854
+ if (options.type && event.type !== options.type)
13855
+ return false;
13856
+ return true;
13857
+ });
13858
+ if (options.dryRun)
13859
+ return { events, deliveries: [] };
13860
+ const deliveries = [];
13861
+ for (const event of events) {
13862
+ deliveries.push(...await this.deliver(event));
13863
+ }
13864
+ return { events, deliveries };
13865
+ }
13866
+ async applyRedaction(event, channel) {
13867
+ let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
13868
+ for (const redactor of this.redactors) {
13869
+ next = await redactor(next, channel);
13870
+ }
13871
+ return next;
13872
+ }
13873
+ async deliverWithRetry(event, channel) {
13874
+ const policy = normalizeRetryPolicy(channel.retry);
13875
+ const attempts = [];
13876
+ for (let index = 0;index < policy.maxAttempts; index += 1) {
13877
+ const attempt = await dispatchChannel2(event, channel, this.transportOptions);
13878
+ attempt.attempt = index + 1;
13879
+ if (attempt.status === "failed" && index + 1 < policy.maxAttempts) {
13880
+ attempt.nextBackoffMs = Math.round(policy.backoffMs * policy.multiplier ** index);
13881
+ }
13882
+ attempts.push(attempt);
13883
+ if (attempt.status !== "failed")
13884
+ break;
13885
+ if (attempt.nextBackoffMs)
13886
+ await Bun.sleep(attempt.nextBackoffMs);
13887
+ }
13888
+ return createDeliveryResult(event, channel, attempts);
13889
+ }
13890
+ }
13891
+ function redactPaths(event, paths, replacement = "[REDACTED]") {
13892
+ if (paths.length === 0)
13893
+ return event;
13894
+ const copy = structuredClone(event);
13895
+ for (const path of paths) {
13896
+ setPath(copy, path, replacement);
13897
+ }
13898
+ return copy;
13899
+ }
13900
+ function sanitizeChannelForOutput(channel) {
13901
+ const copy = structuredClone(channel);
13902
+ if (copy.webhook?.secret)
13903
+ copy.webhook.secret = "[REDACTED]";
13904
+ if (copy.command?.env) {
13905
+ copy.command.env = Object.fromEntries(Object.entries(copy.command.env).map(([key, value]) => [key, shouldRedactKey(key) ? "[REDACTED]" : value]));
13906
+ }
13907
+ return copy;
13908
+ }
13909
+ function sanitizeChannelsForOutput(channels) {
13910
+ return channels.map(sanitizeChannelForOutput);
13911
+ }
13912
+ function redactSensitiveKeys(event, replacement = "[REDACTED]") {
13913
+ return redactValue(event, replacement);
13914
+ }
13915
+ function shouldRedactKey(key) {
13916
+ return /secret|token|password|api[_-]?key|authorization/i.test(key);
13917
+ }
13918
+ function redactValue(value, replacement) {
13919
+ if (Array.isArray(value))
13920
+ return value.map((item) => redactValue(item, replacement));
13921
+ if (!value || typeof value !== "object")
13922
+ return value;
13923
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
13924
+ key,
13925
+ shouldRedactKey(key) ? replacement : redactValue(item, replacement)
13926
+ ]));
13927
+ }
13928
+ function setPath(input, path, replacement) {
13929
+ const parts = path.split(".");
13930
+ let cursor = input;
13931
+ for (const part of parts.slice(0, -1)) {
13932
+ const next = cursor[part];
13933
+ if (!next || typeof next !== "object")
13934
+ return;
13935
+ cursor = next;
13936
+ }
13937
+ const last = parts.at(-1);
13938
+ if (last && last in cursor)
13939
+ cursor[last] = replacement;
13940
+ }
13941
+ function normalizeTime(value) {
13942
+ if (!value)
13943
+ return new Date().toISOString();
13944
+ return value instanceof Date ? value.toISOString() : value;
13945
+ }
13946
+ function normalizeRetryPolicy(policy) {
13947
+ return {
13948
+ maxAttempts: Math.max(1, policy?.maxAttempts ?? 1),
13949
+ backoffMs: Math.max(0, policy?.backoffMs ?? 250),
13950
+ multiplier: Math.max(1, policy?.multiplier ?? 2)
13951
+ };
13952
+ }
13953
+
13954
+ // src/commands/runtime.ts
13955
+ function probeTmuxPane(target, tmuxCommand = process.env["HASNA_MACHINES_TMUX_BIN"] || "tmux") {
13956
+ const checkedAt = new Date().toISOString();
13957
+ const result = spawnSync4(tmuxCommand, ["display-message", "-p", "-t", target, "#{pane_id}"], {
13958
+ encoding: "utf8",
13959
+ timeout: 5000
13960
+ });
13961
+ const stdout = result.stdout?.trim();
13962
+ const stderr = result.stderr?.trim();
13963
+ const exists = result.status === 0 && Boolean(stdout);
13964
+ return {
13965
+ target,
13966
+ exists,
13967
+ paneId: exists ? stdout : undefined,
13968
+ checkedAt,
13969
+ exitCode: result.status,
13970
+ error: result.error?.message,
13971
+ stderr: stderr || undefined
13972
+ };
13973
+ }
13974
+ async function watchTmuxPane(options) {
13975
+ const target = options.target.trim();
13976
+ if (!target)
13977
+ throw new Error("tmux pane target is required");
13978
+ const intervalMs = Math.max(0, options.intervalMs ?? 5000);
13979
+ const maxChecks = options.maxChecks ?? Number.POSITIVE_INFINITY;
13980
+ const client = options.client ?? new EventsClient;
13981
+ const probe = options.probe ?? ((paneTarget) => probeTmuxPane(paneTarget, options.tmuxCommand));
13982
+ const wait = options.sleep ?? sleep;
13983
+ let lastPresent;
13984
+ let lastProbe;
13985
+ for (let checks = 1;checks <= maxChecks; checks += 1) {
13986
+ const current = await probe(target);
13987
+ lastProbe = current;
13988
+ options.onProbe?.(current);
13989
+ if (current.exists) {
13990
+ lastPresent = current;
13991
+ } else if (lastPresent) {
13992
+ const emitted = await emitTmuxEvent(client, "machines.tmux.pane_died", current, lastPresent, options.deliver !== false);
13993
+ return { target, checks, status: "died", lastProbe: current, emitted };
13994
+ } else if (options.emitInitialMissing) {
13995
+ const emitted = await emitTmuxEvent(client, "machines.tmux.pane_missing", current, undefined, options.deliver !== false);
13996
+ return { target, checks, status: "missing", lastProbe: current, emitted };
13997
+ }
13998
+ if (checks < maxChecks)
13999
+ await wait(intervalMs);
14000
+ }
14001
+ if (!lastProbe) {
14002
+ lastProbe = {
14003
+ target,
14004
+ exists: false,
14005
+ checkedAt: new Date().toISOString(),
14006
+ error: "No probe executed"
14007
+ };
14008
+ }
14009
+ return {
14010
+ target,
14011
+ checks: Number.isFinite(maxChecks) ? maxChecks : 0,
14012
+ status: lastProbe.exists ? "present" : "stopped",
14013
+ lastProbe
14014
+ };
14015
+ }
14016
+ async function emitTmuxEvent(client, type, probe, lastPresent, deliver) {
14017
+ const input = {
14018
+ source: "machines",
14019
+ type,
14020
+ subject: `tmux:${probe.target}`,
14021
+ severity: type === "machines.tmux.pane_died" ? "warning" : "notice",
14022
+ message: type === "machines.tmux.pane_died" ? `tmux pane disappeared: ${probe.target}` : `tmux pane is missing: ${probe.target}`,
14023
+ data: {
14024
+ target: probe.target,
14025
+ paneId: lastPresent?.paneId,
14026
+ lastSeenAt: lastPresent?.checkedAt,
14027
+ checkedAt: probe.checkedAt,
14028
+ exitCode: probe.exitCode,
14029
+ error: probe.error,
14030
+ stderr: probe.stderr
14031
+ },
14032
+ metadata: {
14033
+ monitor: "tmux-pane",
14034
+ runtime: "machines"
14035
+ }
14036
+ };
14037
+ return client.emit(input, { deliver });
14038
+ }
13430
14039
  // src/commands/status.ts
13431
14040
  function getStatus() {
13432
14041
  const manifest = readManifest();
@@ -13476,13 +14085,16 @@ function getServeInfo(options = {}) {
13476
14085
  "/api/status",
13477
14086
  "/api/manifest",
13478
14087
  "/api/notifications",
14088
+ "/api/webhooks",
14089
+ "/api/events",
13479
14090
  "/api/doctor",
13480
14091
  "/api/self-test",
13481
14092
  "/api/apps/status",
13482
14093
  "/api/apps/diff",
13483
14094
  "/api/install-claude/status",
13484
14095
  "/api/install-claude/diff",
13485
- "/api/notifications/test"
14096
+ "/api/notifications/test",
14097
+ "/api/webhooks/test"
13486
14098
  ]
13487
14099
  };
13488
14100
  }
@@ -13649,6 +14261,7 @@ function jsonError(message, status = 400) {
13649
14261
  }
13650
14262
  function startDashboardServer(options = {}) {
13651
14263
  const info = getServeInfo(options);
14264
+ const events = new EventsClient;
13652
14265
  return Bun.serve({
13653
14266
  hostname: info.host,
13654
14267
  port: info.port,
@@ -13668,6 +14281,42 @@ function startDashboardServer(options = {}) {
13668
14281
  if (url.pathname === "/api/notifications") {
13669
14282
  return Response.json(listNotificationChannels());
13670
14283
  }
14284
+ if (url.pathname === "/api/webhooks") {
14285
+ if (request.method !== "GET") {
14286
+ return jsonError("Use GET for webhook channel listing.", 405);
14287
+ }
14288
+ return Response.json(sanitizeChannelsForOutput(await events.listChannels()));
14289
+ }
14290
+ if (url.pathname === "/api/events") {
14291
+ if (request.method === "GET") {
14292
+ return Response.json(await events.listEvents());
14293
+ }
14294
+ if (request.method !== "POST") {
14295
+ return jsonError("Use GET or POST for events.", 405);
14296
+ }
14297
+ const body = await parseJsonBody(request);
14298
+ const type = typeof body["type"] === "string" ? body["type"] : undefined;
14299
+ if (!type) {
14300
+ return jsonError("type is required.");
14301
+ }
14302
+ const source = typeof body["source"] === "string" ? body["source"] : "machines";
14303
+ const subject = typeof body["subject"] === "string" ? body["subject"] : undefined;
14304
+ const severity = typeof body["severity"] === "string" ? body["severity"] : undefined;
14305
+ const message = typeof body["message"] === "string" ? body["message"] : undefined;
14306
+ const dedupeKey = typeof body["dedupeKey"] === "string" ? body["dedupeKey"] : undefined;
14307
+ const data = body["data"] && typeof body["data"] === "object" && !Array.isArray(body["data"]) ? body["data"] : {};
14308
+ const metadata = body["metadata"] && typeof body["metadata"] === "object" && !Array.isArray(body["metadata"]) ? body["metadata"] : {};
14309
+ return Response.json(await events.emit({
14310
+ source,
14311
+ type,
14312
+ subject,
14313
+ severity,
14314
+ message,
14315
+ dedupeKey,
14316
+ data,
14317
+ metadata
14318
+ }));
14319
+ }
13671
14320
  if (url.pathname === "/api/doctor") {
13672
14321
  return Response.json(runDoctor(machineId));
13673
14322
  }
@@ -13705,6 +14354,28 @@ function startDashboardServer(options = {}) {
13705
14354
  return jsonError(error instanceof Error ? error.message : String(error));
13706
14355
  }
13707
14356
  }
14357
+ if (url.pathname === "/api/webhooks/test") {
14358
+ if (request.method !== "POST") {
14359
+ return jsonError("Use POST for webhook tests.", 405);
14360
+ }
14361
+ const body = await parseJsonBody(request);
14362
+ const channelId = typeof body["channelId"] === "string" ? body["channelId"] : undefined;
14363
+ if (!channelId) {
14364
+ return jsonError("channelId is required.");
14365
+ }
14366
+ const type = typeof body["type"] === "string" ? body["type"] : "events.test";
14367
+ const message = typeof body["message"] === "string" ? body["message"] : undefined;
14368
+ try {
14369
+ return Response.json(await events.testChannel(channelId, {
14370
+ source: "machines",
14371
+ type,
14372
+ subject: channelId,
14373
+ message
14374
+ }));
14375
+ } catch (error) {
14376
+ return jsonError(error instanceof Error ? error.message : String(error));
14377
+ }
14378
+ }
13708
14379
  return new Response(renderDashboardHtml(), {
13709
14380
  headers: {
13710
14381
  "content-type": "text/html; charset=utf-8"
@@ -13744,7 +14415,7 @@ function runSelfTest() {
13744
14415
  };
13745
14416
  }
13746
14417
  // src/commands/setup.ts
13747
- import { homedir as homedir4 } from "os";
14418
+ import { homedir as homedir5 } from "os";
13748
14419
  function quote3(value) {
13749
14420
  return `'${value.replace(/'/g, `'\\''`)}'`;
13750
14421
  }
@@ -13814,7 +14485,7 @@ function buildSetupPlan(machineId) {
13814
14485
  const target = selected || {
13815
14486
  id: currentMachineId,
13816
14487
  platform: "linux",
13817
- workspacePath: `${homedir4()}/workspace`
14488
+ workspacePath: `${homedir5()}/workspace`
13818
14489
  };
13819
14490
  const steps = [...buildBaseSteps(target), ...buildPackageSteps(target)];
13820
14491
  return {
@@ -13980,8 +14651,8 @@ function buildScreenEnableCommand(machineId, options = {}) {
13980
14651
  };
13981
14652
  }
13982
14653
  // src/commands/sync.ts
13983
- import { existsSync as existsSync7, lstatSync, readFileSync as readFileSync5, symlinkSync, copyFileSync } from "fs";
13984
- import { homedir as homedir5 } from "os";
14654
+ import { existsSync as existsSync8, lstatSync, readFileSync as readFileSync5, symlinkSync, copyFileSync } from "fs";
14655
+ import { homedir as homedir6 } from "os";
13985
14656
  function quote4(value) {
13986
14657
  return `'${value.replace(/'/g, `'\\''`)}'`;
13987
14658
  }
@@ -14029,8 +14700,8 @@ function detectPackageActions(machine) {
14029
14700
  }
14030
14701
  function detectFileActions(machine) {
14031
14702
  return (machine.files || []).map((file, index) => {
14032
- const sourceExists = existsSync7(file.source);
14033
- const targetExists = existsSync7(file.target);
14703
+ const sourceExists = existsSync8(file.source);
14704
+ const targetExists = existsSync8(file.target);
14034
14705
  let status = "missing";
14035
14706
  if (sourceExists && targetExists) {
14036
14707
  if (file.mode === "symlink") {
@@ -14058,7 +14729,7 @@ function buildSyncPlan(machineId) {
14058
14729
  const target = selected || {
14059
14730
  id: currentMachineId,
14060
14731
  platform: "linux",
14061
- workspacePath: `${homedir5()}/workspace`
14732
+ workspacePath: `${homedir6()}/workspace`
14062
14733
  };
14063
14734
  const actions = [
14064
14735
  ...detectPackageActions(target),
@@ -23232,6 +23903,13 @@ var MACHINE_MCP_TOOL_NAMES = [
23232
23903
  "machines_notifications_test",
23233
23904
  "machines_notifications_dispatch",
23234
23905
  "machines_notifications_remove",
23906
+ "machines_webhooks_add",
23907
+ "machines_webhooks_list",
23908
+ "machines_webhooks_test",
23909
+ "machines_webhooks_remove",
23910
+ "machines_events_emit",
23911
+ "machines_events_list",
23912
+ "machines_events_replay",
23235
23913
  "machines_serve_info",
23236
23914
  "machines_serve_dashboard",
23237
23915
  "storage_status",
@@ -23244,6 +23922,7 @@ function buildServer(version2 = getPackageVersion()) {
23244
23922
  }
23245
23923
  function createMcpServer(version2) {
23246
23924
  const server = new McpServer({ name: "machines", version: version2 });
23925
+ const events = new EventsClient;
23247
23926
  server.tool("machines_status", "Return local machine fleet status paths and machine identity.", {}, async () => ({
23248
23927
  content: [{ type: "text", text: JSON.stringify(getStatus(), null, 2) }]
23249
23928
  }));
@@ -23386,8 +24065,8 @@ function createMcpServer(version2) {
23386
24065
  target: exports_external.string().describe("Email, webhook URL, or shell command"),
23387
24066
  events: exports_external.array(exports_external.string()).describe("Events routed to this channel"),
23388
24067
  enabled: exports_external.boolean().optional().describe("Whether the channel is enabled")
23389
- }, async ({ channel_id, type, target, events, enabled }) => ({
23390
- content: [{ type: "text", text: JSON.stringify(addNotificationChannel({ id: channel_id, type, target, events, enabled: enabled ?? true }), null, 2) }]
24068
+ }, async ({ channel_id, type, target, events: events2, enabled }) => ({
24069
+ content: [{ type: "text", text: JSON.stringify(addNotificationChannel({ id: channel_id, type, target, events: events2, enabled: enabled ?? true }), null, 2) }]
23391
24070
  }));
23392
24071
  server.tool("machines_notifications_list", "List notification channels.", {}, async () => ({
23393
24072
  content: [{ type: "text", text: JSON.stringify(listNotificationChannels(), null, 2) }]
@@ -23397,6 +24076,60 @@ function createMcpServer(version2) {
23397
24076
  }));
23398
24077
  server.tool("machines_notifications_dispatch", "Dispatch an event to matching notification channels.", { event: exports_external.string().describe("Event name"), message: exports_external.string().describe("Message body"), channel_id: exports_external.string().optional().describe("Limit delivery to one channel") }, async ({ event, message, channel_id }) => ({ content: [{ type: "text", text: JSON.stringify(await dispatchNotificationEvent(event, message, { channelId: channel_id }), null, 2) }] }));
23399
24078
  server.tool("machines_notifications_remove", "Remove a notification channel.", { channel_id: exports_external.string().describe("Channel identifier") }, async ({ channel_id }) => ({ content: [{ type: "text", text: JSON.stringify(removeNotificationChannel(channel_id), null, 2) }] }));
24079
+ server.tool("machines_webhooks_add", "Add or replace a shared Hasna event webhook channel.", {
24080
+ channel_id: exports_external.string().describe("Channel identifier"),
24081
+ url: exports_external.string().url().describe("Webhook URL"),
24082
+ event_type: exports_external.string().optional().describe("Optional event type filter, e.g. machines.*"),
24083
+ source: exports_external.string().optional().describe("Optional source filter"),
24084
+ secret: exports_external.string().optional().describe("Optional HMAC secret"),
24085
+ enabled: exports_external.boolean().optional().describe("Whether the channel is enabled")
24086
+ }, async ({ channel_id, url, event_type, source, secret, enabled }) => {
24087
+ const now2 = new Date().toISOString();
24088
+ const channel = await events.addChannel({
24089
+ id: channel_id,
24090
+ enabled: enabled ?? true,
24091
+ transport: "webhook",
24092
+ filters: event_type || source ? [{ type: event_type, source }] : undefined,
24093
+ webhook: { url, secret },
24094
+ createdAt: now2,
24095
+ updatedAt: now2
24096
+ });
24097
+ return { content: [{ type: "text", text: JSON.stringify(sanitizeChannelForOutput(channel), null, 2) }] };
24098
+ });
24099
+ server.tool("machines_webhooks_list", "List shared Hasna event webhook channels.", {}, async () => ({
24100
+ content: [{ type: "text", text: JSON.stringify(sanitizeChannelsForOutput(await events.listChannels()), null, 2) }]
24101
+ }));
24102
+ server.tool("machines_webhooks_test", "Send a test event to one shared Hasna event channel.", { channel_id: exports_external.string().describe("Channel identifier"), event_type: exports_external.string().optional().describe("Event type"), message: exports_external.string().optional().describe("Message body") }, async ({ channel_id, event_type, message }) => ({
24103
+ content: [{ type: "text", text: JSON.stringify(await events.testChannel(channel_id, { source: "machines", type: event_type ?? "events.test", message }), null, 2) }]
24104
+ }));
24105
+ server.tool("machines_webhooks_remove", "Remove a shared Hasna event channel.", { channel_id: exports_external.string().describe("Channel identifier") }, async ({ channel_id }) => ({ content: [{ type: "text", text: JSON.stringify({ removed: await events.removeChannel(channel_id) }, null, 2) }] }));
24106
+ server.tool("machines_events_emit", "Emit a shared Hasna event from machines.", {
24107
+ event_type: exports_external.string().describe("Event type"),
24108
+ subject: exports_external.string().optional().describe("Event subject"),
24109
+ severity: exports_external.enum(["debug", "info", "notice", "warning", "error", "critical"]).optional().describe("Event severity"),
24110
+ message: exports_external.string().optional().describe("Message body"),
24111
+ data: exports_external.record(exports_external.unknown()).optional().describe("Event data"),
24112
+ metadata: exports_external.record(exports_external.unknown()).optional().describe("Event metadata"),
24113
+ dedupe_key: exports_external.string().optional().describe("Dedupe key"),
24114
+ deliver: exports_external.boolean().optional().describe("Deliver to matching channels")
24115
+ }, async ({ event_type, subject, severity, message, data, metadata, dedupe_key, deliver }) => ({
24116
+ content: [{ type: "text", text: JSON.stringify(await events.emit({
24117
+ source: "machines",
24118
+ type: event_type,
24119
+ subject,
24120
+ severity,
24121
+ message,
24122
+ data: data ?? {},
24123
+ metadata: metadata ?? {},
24124
+ dedupeKey: dedupe_key
24125
+ }, { deliver: deliver !== false }), null, 2) }]
24126
+ }));
24127
+ server.tool("machines_events_list", "List shared Hasna events.", {}, async () => ({
24128
+ content: [{ type: "text", text: JSON.stringify(await events.listEvents(), null, 2) }]
24129
+ }));
24130
+ server.tool("machines_events_replay", "Replay shared Hasna events.", { event_id: exports_external.string().optional().describe("Event id"), source: exports_external.string().optional().describe("Source filter"), event_type: exports_external.string().optional().describe("Event type filter"), dry_run: exports_external.boolean().optional().describe("Preview without delivery") }, async ({ event_id, source, event_type, dry_run }) => ({
24131
+ content: [{ type: "text", text: JSON.stringify(await events.replay({ eventId: event_id, source, type: event_type, dryRun: dry_run }), null, 2) }]
24132
+ }));
23400
24133
  server.tool("machines_serve_info", "Preview the dashboard server bind address and routes.", { host: exports_external.string().optional().describe("Host interface"), port: exports_external.number().optional().describe("Port number") }, async ({ host, port }) => ({ content: [{ type: "text", text: JSON.stringify(getServeInfo({ host, port }), null, 2) }] }));
23401
24134
  server.tool("machines_serve_dashboard", "Render the current dashboard HTML.", {}, async () => ({
23402
24135
  content: [{ type: "text", text: renderDashboardHtml() }]
@@ -23413,6 +24146,7 @@ export {
23413
24146
  writeNotificationConfig,
23414
24147
  writeManifest,
23415
24148
  writeHeartbeat,
24149
+ watchTmuxPane,
23416
24150
  validateManifest,
23417
24151
  upsertHeartbeat,
23418
24152
  testNotificationChannel,
@@ -23446,6 +24180,7 @@ export {
23446
24180
  recordSetupRun,
23447
24181
  readNotificationConfig,
23448
24182
  readManifest,
24183
+ probeTmuxPane,
23449
24184
  parseStorageTables,
23450
24185
  parsePortOutput,
23451
24186
  markOffline,