@hermespilot/link 0.6.7 → 0.6.9

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.
@@ -4,7 +4,7 @@ import Router from "@koa/router";
4
4
 
5
5
  // src/conversations/conversation-service.ts
6
6
  import { EventEmitter } from "events";
7
- import { randomUUID as randomUUID10 } from "crypto";
7
+ import { createHash as createHash6, randomUUID as randomUUID10 } from "crypto";
8
8
 
9
9
  // src/database/link-database.ts
10
10
  import { mkdir } from "fs/promises";
@@ -1359,6 +1359,7 @@ function isNodeError(error, code) {
1359
1359
  }
1360
1360
 
1361
1361
  // src/storage/atomic-json.ts
1362
+ var jsonUpdateQueues = /* @__PURE__ */ new Map();
1362
1363
  async function readJsonFile(filePath) {
1363
1364
  try {
1364
1365
  const raw = await readFile(filePath, "utf8");
@@ -1375,6 +1376,24 @@ async function writeJsonFile(filePath, value, mode = 384) {
1375
1376
  `;
1376
1377
  await atomicWriteFilePreservingMetadata(filePath, payload, { mode });
1377
1378
  }
1379
+ async function updateJsonFile(filePath, update, mode = 384) {
1380
+ const previous = jsonUpdateQueues.get(filePath) ?? Promise.resolve();
1381
+ let next;
1382
+ const operation = previous.catch(() => void 0).then(async () => {
1383
+ const current = await readJsonFile(filePath);
1384
+ next = await update(current);
1385
+ await writeJsonFile(filePath, next, mode);
1386
+ });
1387
+ const queued = operation.catch(() => void 0);
1388
+ jsonUpdateQueues.set(filePath, queued);
1389
+ void queued.finally(() => {
1390
+ if (jsonUpdateQueues.get(filePath) === queued) {
1391
+ jsonUpdateQueues.delete(filePath);
1392
+ }
1393
+ });
1394
+ await operation;
1395
+ return next;
1396
+ }
1378
1397
  function isNodeError2(error, code) {
1379
1398
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
1380
1399
  }
@@ -5275,6 +5294,10 @@ async function bindCronJobToHermesLink(paths, input) {
5275
5294
  if (existing) {
5276
5295
  existing.conversationId = input.conversationId;
5277
5296
  existing.source = input.source;
5297
+ if (input.ownerAccountId) {
5298
+ existing.ownerAccountId = input.ownerAccountId;
5299
+ existing.ownerAppInstanceId = input.ownerAppInstanceId;
5300
+ }
5278
5301
  } else {
5279
5302
  registry.bindings.push({
5280
5303
  ...input,
@@ -5284,6 +5307,26 @@ async function bindCronJobToHermesLink(paths, input) {
5284
5307
  }
5285
5308
  await writeRegistry(paths, registry);
5286
5309
  }
5310
+ async function backfillHermesLinkCronDeliveryOwner(paths, input) {
5311
+ const accountId = input.accountId.trim();
5312
+ if (!accountId) {
5313
+ return 0;
5314
+ }
5315
+ const registry = await readRegistry(paths);
5316
+ let changed = 0;
5317
+ for (const binding of registry.bindings) {
5318
+ if (binding.ownerAccountId) {
5319
+ continue;
5320
+ }
5321
+ binding.ownerAccountId = accountId;
5322
+ binding.ownerAppInstanceId = input.appInstanceId;
5323
+ changed += 1;
5324
+ }
5325
+ if (changed > 0) {
5326
+ await writeRegistry(paths, registry);
5327
+ }
5328
+ return changed;
5329
+ }
5287
5330
  async function bindNewCronJobsToHermesLink(paths, input) {
5288
5331
  for (const job of input.jobs) {
5289
5332
  const jobId = readString3(job, "id") ?? readString3(job, "job_id");
@@ -5298,7 +5341,9 @@ async function bindNewCronJobsToHermesLink(paths, input) {
5298
5341
  profileName: input.profileName,
5299
5342
  jobId,
5300
5343
  conversationId: input.conversationId,
5301
- source: "natural_language"
5344
+ source: "natural_language",
5345
+ ownerAccountId: input.ownerAccountId,
5346
+ ownerAppInstanceId: input.ownerAppInstanceId
5302
5347
  });
5303
5348
  }
5304
5349
  }
@@ -5323,6 +5368,28 @@ async function decorateHermesLinkCronJob(paths, profileName, job) {
5323
5368
  );
5324
5369
  return binding ? { ...job, deliver: HERMES_LINK_CRON_DELIVER } : job;
5325
5370
  }
5371
+ async function listHermesLinkCronOutputWatchDirs(paths) {
5372
+ const registry = await readRegistry(paths);
5373
+ const dirs = /* @__PURE__ */ new Set();
5374
+ for (const binding of registry.bindings) {
5375
+ dirs.add(
5376
+ path4.join(resolveHermesProfileDir(binding.profileName), "cron", "output")
5377
+ );
5378
+ }
5379
+ const existing = [];
5380
+ for (const dir of dirs) {
5381
+ const dirStat = await stat2(dir).catch((error) => {
5382
+ if (isNodeError4(error, "ENOENT")) {
5383
+ return null;
5384
+ }
5385
+ throw error;
5386
+ });
5387
+ if (dirStat?.isDirectory()) {
5388
+ existing.push(dir);
5389
+ }
5390
+ }
5391
+ return existing;
5392
+ }
5326
5393
  async function syncHermesLinkCronDeliveries(paths, runtime, logger) {
5327
5394
  const registry = await readRegistry(paths);
5328
5395
  let touched = false;
@@ -5339,15 +5406,27 @@ async function syncHermesLinkCronDeliveries(paths, runtime, logger) {
5339
5406
  }
5340
5407
  try {
5341
5408
  const content = await readCronOutput(output.path);
5342
- await runtime.appendCronDelivery({
5409
+ const handled = await runtime.appendCronDelivery({
5343
5410
  conversationId: binding.conversationId,
5344
5411
  profileName: binding.profileName,
5345
5412
  jobId: binding.jobId,
5413
+ source: binding.source,
5346
5414
  jobName: await readCronJobNameFromOutput(content),
5347
5415
  outputPath: output.path,
5348
5416
  content,
5349
- runAt: output.mtime
5417
+ failed: isFailedCronOutput(content),
5418
+ runAt: output.mtime,
5419
+ accountId: binding.ownerAccountId,
5420
+ appInstanceId: binding.ownerAppInstanceId
5350
5421
  });
5422
+ if (!handled) {
5423
+ void logger.warn("cron_link_delivery_pending", {
5424
+ profile: binding.profileName,
5425
+ job_id: binding.jobId,
5426
+ output_path: output.path
5427
+ });
5428
+ continue;
5429
+ }
5351
5430
  delivered.add(output.path);
5352
5431
  touched = true;
5353
5432
  } catch (error) {
@@ -5423,6 +5502,9 @@ async function readCronJobNameFromOutput(content) {
5423
5502
  const match = content.match(/^#\s*Cron Job:\s*(.+)$/mu);
5424
5503
  return match?.[1]?.trim() || void 0;
5425
5504
  }
5505
+ function isFailedCronOutput(content) {
5506
+ return /^#\s*Cron Job:\s*.+\(FAILED\)\s*$/imu.test(content) || /^\*\*Status:\*\*\s*(?:script\s+)?failed\s*$/imu.test(content) || /^##\s+Error\s*$/imu.test(content);
5507
+ }
5426
5508
  async function readRegistry(paths) {
5427
5509
  const existing = await readJsonFile(registryPath(paths));
5428
5510
  if (existing?.version === REGISTRY_VERSION && Array.isArray(existing.bindings)) {
@@ -5449,7 +5531,7 @@ function normalizeDeliverValue(value) {
5449
5531
  }
5450
5532
  function isValidBinding(value) {
5451
5533
  const binding = value;
5452
- return typeof binding?.profileName === "string" && typeof binding.jobId === "string" && typeof binding.conversationId === "string" && (binding.source === "app" || binding.source === "natural_language");
5534
+ return typeof binding?.profileName === "string" && typeof binding.jobId === "string" && (binding.conversationId === void 0 || typeof binding.conversationId === "string") && (binding.source === "app" || binding.source === "natural_language");
5453
5535
  }
5454
5536
  function readString3(record, ...keys) {
5455
5537
  for (const key of keys) {
@@ -5467,30 +5549,15 @@ function isConversationMissingError(error) {
5467
5549
  return isLinkHttpError(error) && error.status === 404 && error.code === "conversation_not_found";
5468
5550
  }
5469
5551
 
5470
- // src/hermes/gateway.ts
5471
- import { execFile as execFile2, spawn } from "child_process";
5472
- import { constants as fsConstants } from "fs";
5473
- import { access, readFile as readFile5, realpath, stat as stat4 } from "fs/promises";
5474
- import path7 from "path";
5475
- import { setTimeout as delay2 } from "timers/promises";
5476
- import { promisify as promisify2 } from "util";
5477
-
5478
- // src/runtime/logger.ts
5479
- import { appendFile, mkdir as mkdir4, open as open2, readFile as readFile4, rename as rename2, rm as rm2, stat as stat3, truncate } from "fs/promises";
5480
- import os3 from "os";
5481
- import path6 from "path";
5482
-
5483
- // src/runtime/paths.ts
5484
- import os2 from "os";
5485
- import path5 from "path";
5486
-
5487
5552
  // src/constants.ts
5488
- var LINK_VERSION = "0.6.7";
5553
+ var LINK_VERSION = "0.6.9";
5489
5554
  var LINK_COMMAND = "hermeslink";
5490
5555
  var LINK_DEFAULT_PORT = 52379;
5491
5556
  var LINK_RUNTIME_DIR_NAME = ".hermeslink";
5492
5557
 
5493
5558
  // src/runtime/paths.ts
5559
+ import os2 from "os";
5560
+ import path5 from "path";
5494
5561
  function resolveRuntimeHome() {
5495
5562
  return process.env.HERMESLINK_HOME?.trim() ? path5.resolve(process.env.HERMESLINK_HOME) : path5.join(os2.homedir(), LINK_RUNTIME_DIR_NAME);
5496
5563
  }
@@ -5511,7 +5578,255 @@ function resolveRuntimePaths(homeDir = resolveRuntimeHome()) {
5511
5578
  };
5512
5579
  }
5513
5580
 
5581
+ // src/config/config.ts
5582
+ var defaultLinkConfig = {
5583
+ port: LINK_DEFAULT_PORT,
5584
+ lanHost: null,
5585
+ serverBaseUrl: "https://hermes-server.clawpilot.me",
5586
+ relayBaseUrl: "https://hermes-relay.clawpilot.me",
5587
+ appConnectTokenIssuer: "https://hermes-server.clawpilot.me",
5588
+ appConnectTokenAudience: "hermes-link",
5589
+ language: "auto",
5590
+ logLevel: "warn"
5591
+ };
5592
+ async function loadConfig(paths = resolveRuntimePaths()) {
5593
+ const existing = await readJsonFile(paths.configFile);
5594
+ const language = normalizeConfiguredLanguage(existing?.language);
5595
+ const lanHost = normalizeLanHost(existing?.lanHost);
5596
+ const logLevel = normalizeLogLevel(
5597
+ existing?.logLevel ?? process.env.HERMESLINK_LOG_LEVEL
5598
+ );
5599
+ return {
5600
+ ...defaultLinkConfig,
5601
+ ...existing ?? {},
5602
+ language,
5603
+ lanHost,
5604
+ logLevel
5605
+ };
5606
+ }
5607
+ async function saveConfig(patch, paths = resolveRuntimePaths()) {
5608
+ const current = await loadConfig(paths);
5609
+ const next = {
5610
+ ...current,
5611
+ ...patch,
5612
+ logLevel: patch.logLevel === void 0 ? current.logLevel : normalizeLogLevel(patch.logLevel)
5613
+ };
5614
+ await writeJsonFile(paths.configFile, next);
5615
+ return next;
5616
+ }
5617
+ function normalizeConfiguredLanguage(language) {
5618
+ if (language === "zh-CN" || language === "en" || language === "auto") {
5619
+ return language;
5620
+ }
5621
+ return defaultLinkConfig.language;
5622
+ }
5623
+ function normalizeLogLevel(level) {
5624
+ if (level === "debug" || level === "info" || level === "warn" || level === "error") {
5625
+ return level;
5626
+ }
5627
+ return defaultLinkConfig.logLevel;
5628
+ }
5629
+ function parseLogLevel(value) {
5630
+ if (value === "debug" || value === "info" || value === "warn" || value === "error") {
5631
+ return value;
5632
+ }
5633
+ return null;
5634
+ }
5635
+ function normalizeLanHost(value) {
5636
+ if (value === null || value === void 0) {
5637
+ return null;
5638
+ }
5639
+ if (typeof value !== "string") {
5640
+ return null;
5641
+ }
5642
+ const host = value.trim().replace(/^\[/u, "").replace(/\]$/u, "");
5643
+ if (!host) {
5644
+ return null;
5645
+ }
5646
+ if (!isUsableLanIpv4(host)) {
5647
+ return null;
5648
+ }
5649
+ return host;
5650
+ }
5651
+ function isUsableLanIpv4(value) {
5652
+ const parts = value.split(".").map((part) => Number.parseInt(part, 10));
5653
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
5654
+ return false;
5655
+ }
5656
+ const [first, second, , fourth] = parts;
5657
+ const privateRange = first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
5658
+ return privateRange && fourth !== 0 && fourth !== 255;
5659
+ }
5660
+
5661
+ // src/identity/identity.ts
5662
+ import { generateKeyPairSync, randomUUID as randomUUID3, sign } from "crypto";
5663
+ import { mkdir as mkdir4, chmod as chmod2 } from "fs/promises";
5664
+ import { z } from "zod";
5665
+ var linkIdentitySchema = z.object({
5666
+ install_id: z.string().min(1),
5667
+ link_id: z.string().min(1).nullable().optional(),
5668
+ public_key_pem: z.string().min(1),
5669
+ private_key_pem: z.string().min(1),
5670
+ created_at: z.string().min(1),
5671
+ updated_at: z.string().min(1)
5672
+ });
5673
+ async function loadIdentity(paths = resolveRuntimePaths()) {
5674
+ const value = await readJsonFile(paths.identityFile);
5675
+ if (value === null) {
5676
+ return null;
5677
+ }
5678
+ return linkIdentitySchema.parse(value);
5679
+ }
5680
+ async function ensureIdentity(paths = resolveRuntimePaths()) {
5681
+ const existing = await loadIdentity(paths);
5682
+ if (existing) {
5683
+ return existing;
5684
+ }
5685
+ await mkdir4(paths.homeDir, { recursive: true, mode: 448 });
5686
+ await chmod2(paths.homeDir, 448).catch(() => void 0);
5687
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
5688
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5689
+ const identity = {
5690
+ install_id: `install_${randomUUID3().replaceAll("-", "")}`,
5691
+ link_id: null,
5692
+ public_key_pem: publicKey.export({ type: "spki", format: "pem" }).toString(),
5693
+ private_key_pem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
5694
+ created_at: now,
5695
+ updated_at: now
5696
+ };
5697
+ await writeJsonFile(paths.identityFile, identity);
5698
+ return identity;
5699
+ }
5700
+ async function saveAssignedLinkId(linkId, paths = resolveRuntimePaths()) {
5701
+ const identity = await ensureIdentity(paths);
5702
+ const next = {
5703
+ ...identity,
5704
+ link_id: linkId,
5705
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
5706
+ };
5707
+ await writeJsonFile(paths.identityFile, next);
5708
+ return next;
5709
+ }
5710
+ function signRelayNonce(identity, nonce) {
5711
+ return signIdentityPayload(identity, nonce);
5712
+ }
5713
+ function signIdentityPayload(identity, payload) {
5714
+ const signature = sign(null, Buffer.from(payload, "utf8"), identity.private_key_pem);
5715
+ return signature.toString("base64url");
5716
+ }
5717
+ function getIdentityStatus(identity) {
5718
+ return {
5719
+ installId: identity.install_id,
5720
+ linkId: identity.link_id ?? null,
5721
+ hasPrivateKey: identity.private_key_pem.trim().length > 0,
5722
+ publicKeyPem: identity.public_key_pem
5723
+ };
5724
+ }
5725
+
5726
+ // src/link/notification-events.ts
5727
+ async function reportNotificationEventToServer(options) {
5728
+ const [identity, config] = await Promise.all([
5729
+ loadIdentity(options.paths),
5730
+ loadConfig(options.paths)
5731
+ ]);
5732
+ if (!identity?.link_id) {
5733
+ return;
5734
+ }
5735
+ const occurredAt = normalizeIso(options.event.occurredAt) ?? (/* @__PURE__ */ new Date()).toISOString();
5736
+ const reportedAt = (/* @__PURE__ */ new Date()).toISOString();
5737
+ const payload = {
5738
+ type: "hermes_link_notification_event",
5739
+ link_id: identity.link_id,
5740
+ install_id: identity.install_id,
5741
+ source_event_id: options.event.sourceEventId,
5742
+ event_kind: options.event.eventKind,
5743
+ conversation_id: options.event.conversationId,
5744
+ occurred_at: occurredAt,
5745
+ reported_at: reportedAt
5746
+ };
5747
+ addOptional(payload, "account_id", options.event.accountId, 128);
5748
+ addOptional(payload, "message_id", options.event.messageId, 128);
5749
+ addOptional(payload, "run_id", options.event.runId, 128);
5750
+ addOptional(payload, "body_preview", options.event.bodyPreview, 512);
5751
+ addOptional(payload, "conversation_title", options.event.conversationTitle, 128);
5752
+ const signature = signIdentityPayload(identity, canonicalJson(payload));
5753
+ const fetcher = options.fetchImpl ?? fetch;
5754
+ const response = await fetcher(
5755
+ `${config.serverBaseUrl.replace(/\/+$/u, "")}/api/v1/links/${encodeURIComponent(identity.link_id)}/notification-events`,
5756
+ {
5757
+ method: "POST",
5758
+ headers: {
5759
+ accept: "application/json",
5760
+ "content-type": "application/json"
5761
+ },
5762
+ body: JSON.stringify({
5763
+ ...payload,
5764
+ public_key_pem: identity.public_key_pem,
5765
+ signature
5766
+ })
5767
+ }
5768
+ );
5769
+ if (!response.ok) {
5770
+ const body = await response.json().catch(() => null);
5771
+ const message = readErrorMessage(body) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
5772
+ throw new LinkHttpError(response.status, "server_request_failed", message);
5773
+ }
5774
+ }
5775
+ function addOptional(payload, key, value, maxLength) {
5776
+ const normalized = value?.trim();
5777
+ if (normalized) {
5778
+ payload[key] = normalized.slice(0, maxLength);
5779
+ }
5780
+ }
5781
+ function normalizeIso(value) {
5782
+ const normalized = value?.trim();
5783
+ if (!normalized) {
5784
+ return null;
5785
+ }
5786
+ const date = new Date(normalized);
5787
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
5788
+ }
5789
+ function canonicalJson(value) {
5790
+ return JSON.stringify(sortJsonValue(value));
5791
+ }
5792
+ function sortJsonValue(value) {
5793
+ if (Array.isArray(value)) {
5794
+ return value.map(sortJsonValue);
5795
+ }
5796
+ if (value && typeof value === "object") {
5797
+ const record = value;
5798
+ const sorted = {};
5799
+ for (const key of Object.keys(record).sort()) {
5800
+ sorted[key] = sortJsonValue(record[key]);
5801
+ }
5802
+ return sorted;
5803
+ }
5804
+ return value;
5805
+ }
5806
+ function readErrorMessage(payload) {
5807
+ if (typeof payload !== "object" || payload === null) {
5808
+ return null;
5809
+ }
5810
+ const error = payload.error;
5811
+ if (typeof error !== "object" || error === null) {
5812
+ return null;
5813
+ }
5814
+ const message = error.message;
5815
+ return typeof message === "string" ? message : null;
5816
+ }
5817
+
5818
+ // src/hermes/gateway.ts
5819
+ import { execFile as execFile2, spawn } from "child_process";
5820
+ import { constants as fsConstants } from "fs";
5821
+ import { access, readFile as readFile5, realpath, stat as stat4 } from "fs/promises";
5822
+ import path7 from "path";
5823
+ import { setTimeout as delay2 } from "timers/promises";
5824
+ import { promisify as promisify2 } from "util";
5825
+
5514
5826
  // src/runtime/logger.ts
5827
+ import { appendFile, mkdir as mkdir5, open as open2, readFile as readFile4, rename as rename2, rm as rm2, stat as stat3, truncate } from "fs/promises";
5828
+ import os3 from "os";
5829
+ import path6 from "path";
5515
5830
  var DEFAULT_LOG_FILE = "hermeslink.log";
5516
5831
  var DEFAULT_MAX_FILE_BYTES = 1024 * 1024;
5517
5832
  var DEFAULT_MAX_FILES = 5;
@@ -5572,7 +5887,7 @@ var FileLogger = class {
5572
5887
  return this.queue;
5573
5888
  }
5574
5889
  async appendEntry(entry) {
5575
- await mkdir4(this.paths.logsDir, { recursive: true, mode: 448 });
5890
+ await mkdir5(this.paths.logsDir, { recursive: true, mode: 448 });
5576
5891
  const line = `${JSON.stringify(entry)}
5577
5892
  `;
5578
5893
  await this.rotateIfNeeded(Buffer.byteLength(line, "utf8"));
@@ -5608,7 +5923,7 @@ function createRotatingTextLogWriter(options) {
5608
5923
  return queue;
5609
5924
  }
5610
5925
  const next = queue.then(async () => {
5611
- await mkdir4(paths.logsDir, { recursive: true, mode: 448 });
5926
+ await mkdir5(paths.logsDir, { recursive: true, mode: 448 });
5612
5927
  await rotateLogFileIfNeeded(filePath, buffer.length, maxFileBytes, maxFiles);
5613
5928
  await appendFile(filePath, buffer, { mode: 384 });
5614
5929
  }).catch(() => void 0);
@@ -7546,8 +7861,8 @@ function firstRecord(...values) {
7546
7861
  }
7547
7862
 
7548
7863
  // src/conversations/blob-store.ts
7549
- import { randomUUID as randomUUID3 } from "crypto";
7550
- import { mkdir as mkdir5, readFile as readFile6, readdir as readdir4, rm as rm3, stat as stat5, writeFile } from "fs/promises";
7864
+ import { randomUUID as randomUUID4 } from "crypto";
7865
+ import { mkdir as mkdir6, readFile as readFile6, readdir as readdir4, rm as rm3, stat as stat5, writeFile } from "fs/promises";
7551
7866
  import path9 from "path";
7552
7867
 
7553
7868
  // src/conversations/media.ts
@@ -8000,9 +8315,9 @@ async function writeConversationBlob(paths, conversationId, input, options) {
8000
8315
  if (input.bytes.byteLength > options.maxBytes) {
8001
8316
  throw new LinkHttpError(413, "blob_too_large", "Blob is too large");
8002
8317
  }
8003
- const id = `blob_${randomUUID3().replaceAll("-", "")}`;
8318
+ const id = `blob_${randomUUID4().replaceAll("-", "")}`;
8004
8319
  const filePath = blobPath(paths, id);
8005
- await mkdir5(path9.dirname(filePath), { recursive: true, mode: 448 });
8320
+ await mkdir6(path9.dirname(filePath), { recursive: true, mode: 448 });
8006
8321
  await writeFile(filePath, input.bytes, { mode: 384 });
8007
8322
  const blob = {
8008
8323
  id,
@@ -8082,7 +8397,7 @@ async function materializeConversationBlob(paths, conversationId, blobId, manife
8082
8397
  targetDir,
8083
8398
  materializedAttachmentFilename(blobId, manifest.filename ?? blobId)
8084
8399
  );
8085
- await mkdir5(targetDir, { recursive: true, mode: 448 });
8400
+ await mkdir6(targetDir, { recursive: true, mode: 448 });
8086
8401
  await writeFile(targetPath, await readFile6(blobPath(paths, blobId)), {
8087
8402
  mode: 384
8088
8403
  });
@@ -8112,7 +8427,7 @@ async function pruneConversationBlobReference(paths, conversationId, blobId) {
8112
8427
  }
8113
8428
  async function listConversationBlobIds(paths, conversationId) {
8114
8429
  assertValidConversationId(conversationId);
8115
- await mkdir5(paths.blobsDir, { recursive: true, mode: 448 });
8430
+ await mkdir6(paths.blobsDir, { recursive: true, mode: 448 });
8116
8431
  const entries = await readdir4(paths.blobsDir, {
8117
8432
  withFileTypes: true
8118
8433
  }).catch((error) => {
@@ -8340,6 +8655,9 @@ function hasRunningRuns(snapshot) {
8340
8655
  function hasQueuedRuns(snapshot) {
8341
8656
  return snapshot.runs.some((run) => run.status === "queued");
8342
8657
  }
8658
+ function queuedRunCount(snapshot) {
8659
+ return snapshot.runs.filter((run) => run.status === "queued").length;
8660
+ }
8343
8661
  function buildConversationEventStreamState(snapshot) {
8344
8662
  const pendingApprovalRunIds = /* @__PURE__ */ new Set();
8345
8663
  let hasPendingApproval = false;
@@ -8437,7 +8755,7 @@ function isRealtimeRunStatus(status) {
8437
8755
  }
8438
8756
 
8439
8757
  // src/conversations/slash-commands.ts
8440
- import { randomUUID as randomUUID4 } from "crypto";
8758
+ import { randomUUID as randomUUID5 } from "crypto";
8441
8759
  var MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,127}$/u;
8442
8760
  function isValidModelId(value) {
8443
8761
  return MODEL_ID_PATTERN.test(value);
@@ -8534,7 +8852,7 @@ function parseSlashCommandInput(content) {
8534
8852
  }
8535
8853
  function createSlashCommandUserMessage(input) {
8536
8854
  return {
8537
- id: `msg_${randomUUID4().replaceAll("-", "")}`,
8855
+ id: `msg_${randomUUID5().replaceAll("-", "")}`,
8538
8856
  schema_version: 1,
8539
8857
  conversation_id: input.conversationId,
8540
8858
  role: "user",
@@ -8568,7 +8886,7 @@ function slashHelpMessage() {
8568
8886
  ].join("\n");
8569
8887
  }
8570
8888
  function freshHermesSessionId(conversationId) {
8571
- return `hp_${conversationId}_${randomUUID4().replaceAll("-", "").slice(0, 12)}`;
8889
+ return `hp_${conversationId}_${randomUUID5().replaceAll("-", "").slice(0, 12)}`;
8572
8890
  }
8573
8891
  function nextVerboseMode(current) {
8574
8892
  const modes = [
@@ -8909,11 +9227,11 @@ function formatContextUsageLines(runtime) {
8909
9227
  }
8910
9228
 
8911
9229
  // src/conversations/delivery-staging.ts
8912
- import { mkdir as mkdir6, rm as rm4 } from "fs/promises";
9230
+ import { mkdir as mkdir7, rm as rm4 } from "fs/promises";
8913
9231
  import path10 from "path";
8914
9232
  async function prepareDeliveryStagingRunDir(paths, conversationId, runId) {
8915
9233
  const directory = deliveryStagingRunDir(paths, conversationId, runId);
8916
- await mkdir6(directory, { recursive: true, mode: 448 });
9234
+ await mkdir7(directory, { recursive: true, mode: 448 });
8917
9235
  return directory;
8918
9236
  }
8919
9237
  async function removeConversationDeliveryStaging(paths, conversationId) {
@@ -8938,8 +9256,8 @@ function safePathSegment(value, fallback) {
8938
9256
  }
8939
9257
 
8940
9258
  // src/conversations/conversation-archive-plans.ts
8941
- import { randomUUID as randomUUID5 } from "crypto";
8942
- import { mkdir as mkdir7 } from "fs/promises";
9259
+ import { randomUUID as randomUUID6 } from "crypto";
9260
+ import { mkdir as mkdir8 } from "fs/promises";
8943
9261
  import path11 from "path";
8944
9262
  var PLAN_ID_PATTERN = /^archive_[a-f0-9]{32}$/u;
8945
9263
  var ConversationArchivePlanStore = class {
@@ -8950,7 +9268,7 @@ var ConversationArchivePlanStore = class {
8950
9268
  async create(conversationIds) {
8951
9269
  const now = (/* @__PURE__ */ new Date()).toISOString();
8952
9270
  const plan = {
8953
- id: `archive_${randomUUID5().replaceAll("-", "")}`,
9271
+ id: `archive_${randomUUID6().replaceAll("-", "")}`,
8954
9272
  status: "prepared",
8955
9273
  created_at: now,
8956
9274
  updated_at: now,
@@ -8979,7 +9297,7 @@ var ConversationArchivePlanStore = class {
8979
9297
  }
8980
9298
  async write(plan) {
8981
9299
  const normalizedPlanId = normalizePlanId(plan.id);
8982
- await mkdir7(this.plansDir(), { recursive: true, mode: 448 });
9300
+ await mkdir8(this.plansDir(), { recursive: true, mode: 448 });
8983
9301
  await writeJsonFile(this.planPath(normalizedPlanId), plan);
8984
9302
  }
8985
9303
  plansDir() {
@@ -9002,8 +9320,8 @@ function normalizePlanId(planId) {
9002
9320
  }
9003
9321
 
9004
9322
  // src/conversations/conversation-clear-plans.ts
9005
- import { randomUUID as randomUUID6 } from "crypto";
9006
- import { mkdir as mkdir8 } from "fs/promises";
9323
+ import { randomUUID as randomUUID7 } from "crypto";
9324
+ import { mkdir as mkdir9 } from "fs/promises";
9007
9325
  import path12 from "path";
9008
9326
  var PLAN_ID_PATTERN2 = /^clear_[a-f0-9]{32}$/u;
9009
9327
  var ConversationClearPlanStore = class {
@@ -9014,7 +9332,7 @@ var ConversationClearPlanStore = class {
9014
9332
  async create(conversationIds, targetStatus = "active") {
9015
9333
  const now = (/* @__PURE__ */ new Date()).toISOString();
9016
9334
  const plan = {
9017
- id: `clear_${randomUUID6().replaceAll("-", "")}`,
9335
+ id: `clear_${randomUUID7().replaceAll("-", "")}`,
9018
9336
  status: "prepared",
9019
9337
  target_status: targetStatus,
9020
9338
  created_at: now,
@@ -9044,7 +9362,7 @@ var ConversationClearPlanStore = class {
9044
9362
  }
9045
9363
  async write(plan) {
9046
9364
  const normalizedPlanId = normalizePlanId2(plan.id);
9047
- await mkdir8(this.plansDir(), { recursive: true, mode: 448 });
9365
+ await mkdir9(this.plansDir(), { recursive: true, mode: 448 });
9048
9366
  await writeJsonFile(this.planPath(normalizedPlanId), plan);
9049
9367
  }
9050
9368
  plansDir() {
@@ -9123,6 +9441,7 @@ var ConversationMaintenanceCoordinator = class {
9123
9441
  clearPlans;
9124
9442
  archivePlans;
9125
9443
  async prepareClearAllConversationPlan(targetStatus = "active") {
9444
+ assertArchivedClearPlanTarget(targetStatus);
9126
9445
  const targets = [];
9127
9446
  for (const conversationId of await this.deps.store.listConversationIds()) {
9128
9447
  const manifest = await this.deps.store.readManifest(conversationId).catch(() => null);
@@ -9145,6 +9464,7 @@ var ConversationMaintenanceCoordinator = class {
9145
9464
  }
9146
9465
  async executeClearAllConversationPlan(planId) {
9147
9466
  let plan = await this.clearPlans.read(planId);
9467
+ assertArchivedClearPlanTarget(plan.target_status ?? "active");
9148
9468
  if (plan.status === "completed") {
9149
9469
  return plan;
9150
9470
  }
@@ -9207,6 +9527,7 @@ var ConversationMaintenanceCoordinator = class {
9207
9527
  }
9208
9528
  async startClearAllConversationPlan(planId) {
9209
9529
  const plan = await this.clearPlans.read(planId);
9530
+ assertArchivedClearPlanTarget(plan.target_status ?? "active");
9210
9531
  if (plan.status === "completed" || plan.status === "executing") {
9211
9532
  return plan;
9212
9533
  }
@@ -9688,6 +10009,16 @@ var ConversationMaintenanceCoordinator = class {
9688
10009
  return plan;
9689
10010
  }
9690
10011
  };
10012
+ function assertArchivedClearPlanTarget(targetStatus) {
10013
+ if (targetStatus === "archived") {
10014
+ return;
10015
+ }
10016
+ throw new LinkHttpError(
10017
+ 409,
10018
+ "active_conversation_clear_plan_disabled",
10019
+ "Bulk deletion of active conversations is disabled. Archive active conversations first, or delete explicitly selected conversations."
10020
+ );
10021
+ }
9691
10022
  function isVoiceAttachmentInput(attachment) {
9692
10023
  return attachment.kind === "voice" || attachment.type === "voice" || attachment.is_voice_note === true || attachment.isVoiceNote === true;
9693
10024
  }
@@ -9705,86 +10036,6 @@ function readAttachmentWaveform(attachment) {
9705
10036
  ).filter((item) => item !== void 0).slice(0, 96);
9706
10037
  }
9707
10038
 
9708
- // src/config/config.ts
9709
- var defaultLinkConfig = {
9710
- port: LINK_DEFAULT_PORT,
9711
- lanHost: null,
9712
- serverBaseUrl: "https://hermes-server.clawpilot.me",
9713
- relayBaseUrl: "https://hermes-relay.clawpilot.me",
9714
- appConnectTokenIssuer: "https://hermes-server.clawpilot.me",
9715
- appConnectTokenAudience: "hermes-link",
9716
- language: "auto",
9717
- logLevel: "warn"
9718
- };
9719
- async function loadConfig(paths = resolveRuntimePaths()) {
9720
- const existing = await readJsonFile(paths.configFile);
9721
- const language = normalizeConfiguredLanguage(existing?.language);
9722
- const lanHost = normalizeLanHost(existing?.lanHost);
9723
- const logLevel = normalizeLogLevel(
9724
- existing?.logLevel ?? process.env.HERMESLINK_LOG_LEVEL
9725
- );
9726
- return {
9727
- ...defaultLinkConfig,
9728
- ...existing ?? {},
9729
- language,
9730
- lanHost,
9731
- logLevel
9732
- };
9733
- }
9734
- async function saveConfig(patch, paths = resolveRuntimePaths()) {
9735
- const current = await loadConfig(paths);
9736
- const next = {
9737
- ...current,
9738
- ...patch,
9739
- logLevel: patch.logLevel === void 0 ? current.logLevel : normalizeLogLevel(patch.logLevel)
9740
- };
9741
- await writeJsonFile(paths.configFile, next);
9742
- return next;
9743
- }
9744
- function normalizeConfiguredLanguage(language) {
9745
- if (language === "zh-CN" || language === "en" || language === "auto") {
9746
- return language;
9747
- }
9748
- return defaultLinkConfig.language;
9749
- }
9750
- function normalizeLogLevel(level) {
9751
- if (level === "debug" || level === "info" || level === "warn" || level === "error") {
9752
- return level;
9753
- }
9754
- return defaultLinkConfig.logLevel;
9755
- }
9756
- function parseLogLevel(value) {
9757
- if (value === "debug" || value === "info" || value === "warn" || value === "error") {
9758
- return value;
9759
- }
9760
- return null;
9761
- }
9762
- function normalizeLanHost(value) {
9763
- if (value === null || value === void 0) {
9764
- return null;
9765
- }
9766
- if (typeof value !== "string") {
9767
- return null;
9768
- }
9769
- const host = value.trim().replace(/^\[/u, "").replace(/\]$/u, "");
9770
- if (!host) {
9771
- return null;
9772
- }
9773
- if (!isUsableLanIpv4(host)) {
9774
- return null;
9775
- }
9776
- return host;
9777
- }
9778
- function isUsableLanIpv4(value) {
9779
- const parts = value.split(".").map((part) => Number.parseInt(part, 10));
9780
- if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
9781
- return false;
9782
- }
9783
- const [first, second, , fourth] = parts;
9784
- const privateRange = first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
9785
- return privateRange && fourth !== 0 && fourth !== 255;
9786
- }
9787
-
9788
10039
  // src/hermes/session-title.ts
9789
10040
  import { stat as stat7 } from "fs/promises";
9790
10041
  import path13 from "path";
@@ -10247,7 +10498,7 @@ function stripCompressionTitleSuffix(value) {
10247
10498
  }
10248
10499
 
10249
10500
  // src/conversations/conversation-turns.ts
10250
- import { randomUUID as randomUUID7 } from "crypto";
10501
+ import { randomUUID as randomUUID8 } from "crypto";
10251
10502
  var MESSAGE_ORDER_STEP_MS = 10;
10252
10503
  var ASSISTANT_ORDER_OFFSET_MS = 1;
10253
10504
  function createAgentTurnDraft(input) {
@@ -10262,6 +10513,7 @@ function createAgentTurnDraft(input) {
10262
10513
  conversation_id: input.manifest.id,
10263
10514
  role: "user",
10264
10515
  status: shouldQueue ? "queued" : "completed",
10516
+ run_id: runId,
10265
10517
  client_message_id: input.idempotencyKey,
10266
10518
  created_at: userCreatedAt,
10267
10519
  updated_at: now,
@@ -10305,6 +10557,8 @@ function createAgentTurnDraft(input) {
10305
10557
  profile_uid: input.runtime.profileUid,
10306
10558
  profile_name_snapshot: input.runtime.profileName,
10307
10559
  profile: input.runtime.profileName,
10560
+ owner_account_id: input.accountId,
10561
+ owner_app_instance_id: input.appInstanceId,
10308
10562
  model: input.runtime.model,
10309
10563
  provider: input.runtime.provider,
10310
10564
  context_window: input.runtime.contextWindow
@@ -10481,10 +10735,10 @@ function createAssistantMessage(input) {
10481
10735
  };
10482
10736
  }
10483
10737
  function createMessageId() {
10484
- return `msg_${randomUUID7().replaceAll("-", "")}`;
10738
+ return `msg_${randomUUID8().replaceAll("-", "")}`;
10485
10739
  }
10486
10740
  function createRunId() {
10487
- return `run_${randomUUID7().replaceAll("-", "")}`;
10741
+ return `run_${randomUUID8().replaceAll("-", "")}`;
10488
10742
  }
10489
10743
  function hasActiveOrQueuedRuns(snapshot) {
10490
10744
  return snapshot.runs.some(
@@ -10496,6 +10750,9 @@ function validTimestampOrNow(value) {
10496
10750
  return Number.isFinite(timestamp) ? timestamp : Date.now();
10497
10751
  }
10498
10752
 
10753
+ // src/conversations/queue-policy.ts
10754
+ var MAX_CONVERSATION_QUEUED_RUNS = 10;
10755
+
10499
10756
  // src/conversations/conversation-orchestration.ts
10500
10757
  var ConversationOrchestrationCoordinator = class {
10501
10758
  constructor(deps) {
@@ -10526,6 +10783,47 @@ var ConversationOrchestrationCoordinator = class {
10526
10783
  async appendCommandResult(input) {
10527
10784
  return this.appendCommandResultLocked(input);
10528
10785
  }
10786
+ async guideQueuedRun(conversationId, runId) {
10787
+ const result = await this.deps.withConversationLock(
10788
+ conversationId,
10789
+ () => this.guideQueuedRunLocked(conversationId, runId)
10790
+ );
10791
+ if (result.runningRunId) {
10792
+ void this.deps.runLifecycle.cancelRun(conversationId, result.runningRunId, {
10793
+ abortUpstream: true,
10794
+ reason: "interrupted by guided queued message"
10795
+ }).then((cancelResult) => {
10796
+ if (cancelResult.run.status === "cancelled") {
10797
+ return this.startNextQueuedRun(conversationId);
10798
+ }
10799
+ }).catch((error) => {
10800
+ void this.deps.logger.warn("conversation_guided_interrupt_failed", {
10801
+ conversation_id: conversationId,
10802
+ queued_run_id: runId,
10803
+ running_run_id: result.runningRunId,
10804
+ error: error instanceof Error ? error.message : String(error)
10805
+ });
10806
+ });
10807
+ } else {
10808
+ void this.startNextQueuedRun(conversationId).catch((error) => {
10809
+ void this.deps.logger.warn("conversation_queue_drain_failed", {
10810
+ conversation_id: conversationId,
10811
+ error: error instanceof Error ? error.message : String(error)
10812
+ });
10813
+ });
10814
+ }
10815
+ return {
10816
+ conversation_id: result.conversation_id,
10817
+ queued_run: result.queued_run,
10818
+ last_event_seq: result.last_event_seq
10819
+ };
10820
+ }
10821
+ async cancelQueuedRun(conversationId, runId) {
10822
+ return this.deps.withConversationLock(
10823
+ conversationId,
10824
+ () => this.cancelQueuedRunLocked(conversationId, runId)
10825
+ );
10826
+ }
10529
10827
  startRunWorkerAndDrain(conversationId, runId, input) {
10530
10828
  void this.deps.runLifecycle.startRunWorker(conversationId, runId, input).catch(async (error) => {
10531
10829
  if (isConversationNotFoundError(error)) {
@@ -10695,6 +10993,13 @@ var ConversationOrchestrationCoordinator = class {
10695
10993
  }
10696
10994
  }
10697
10995
  const runtime = await readCurrentConversationRuntime(this.deps.paths, manifest);
10996
+ if ((hasRunningRuns(snapshot) || queuedRunCount(snapshot) > 0) && queuedRunCount(snapshot) >= MAX_CONVERSATION_QUEUED_RUNS) {
10997
+ throw new LinkHttpError(
10998
+ 409,
10999
+ "conversation_queue_limit_reached",
11000
+ `conversation queue can contain at most ${MAX_CONVERSATION_QUEUED_RUNS} messages`
11001
+ );
11002
+ }
10698
11003
  const { userMessage, assistantMessage, run, shouldQueue } = createAgentTurnDraft({
10699
11004
  manifest,
10700
11005
  snapshot,
@@ -10702,14 +11007,27 @@ var ConversationOrchestrationCoordinator = class {
10702
11007
  content,
10703
11008
  attachments: userAttachmentParts,
10704
11009
  rawAttachments: input.attachments ?? [],
10705
- idempotencyKey
11010
+ idempotencyKey,
11011
+ accountId: input.accountId,
11012
+ appInstanceId: input.appInstanceId
10706
11013
  });
11014
+ if (input.accountId) {
11015
+ manifest = {
11016
+ ...manifest,
11017
+ owner_account_id: input.accountId,
11018
+ owner_app_instance_id: input.appInstanceId,
11019
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
11020
+ };
11021
+ }
10707
11022
  const assistantMessageId = run.assistant_message_id;
10708
11023
  snapshot.messages.push(
10709
11024
  ...assistantMessage ? [userMessage, assistantMessage] : [userMessage]
10710
11025
  );
10711
11026
  snapshot.runs.push(run);
10712
11027
  await this.deps.store.writeSnapshot(manifest.id, snapshot);
11028
+ if (input.accountId) {
11029
+ await this.deps.store.writeManifest(manifest);
11030
+ }
10713
11031
  manifest = await this.deps.metadata.applyTemporaryTitleFromFirstMessage(
10714
11032
  manifest,
10715
11033
  snapshot,
@@ -10847,6 +11165,137 @@ var ConversationOrchestrationCoordinator = class {
10847
11165
  return this.deps.commandHandlers.restartGatewayFromCommand(input);
10848
11166
  }
10849
11167
  }
11168
+ async guideQueuedRunLocked(conversationId, runId) {
11169
+ const manifest = await this.deps.store.readRunnableManifest(conversationId);
11170
+ const snapshot = await this.deps.store.readSnapshot(conversationId);
11171
+ const run = snapshot.runs.find((item) => item.id === runId);
11172
+ if (!run) {
11173
+ throw new LinkHttpError(404, "run_not_found", "Run was not found");
11174
+ }
11175
+ if (run.status === "running") {
11176
+ throw new LinkHttpError(
11177
+ 409,
11178
+ "queued_run_already_started",
11179
+ "Queued run has already started"
11180
+ );
11181
+ }
11182
+ if (run.status !== "queued") {
11183
+ throw new LinkHttpError(
11184
+ 409,
11185
+ "queued_run_not_active",
11186
+ "Queued run is no longer active"
11187
+ );
11188
+ }
11189
+ const now = (/* @__PURE__ */ new Date()).toISOString();
11190
+ const runningRun = snapshot.runs.find((item) => item.status === "running");
11191
+ run.queue_mode = "guided_interrupt";
11192
+ run.queue_promoted_at = now;
11193
+ if (runningRun) {
11194
+ run.guided_after_run_id = runningRun.id;
11195
+ }
11196
+ const user = snapshot.messages.find(
11197
+ (message) => message.id === run.trigger_message_id
11198
+ );
11199
+ if (user) {
11200
+ user.updated_at = now;
11201
+ user.hermes = {
11202
+ ...user.hermes ?? {},
11203
+ queue_mode: "guided_interrupt",
11204
+ queue_promoted_at: now,
11205
+ ...runningRun ? { guided_after_run_id: runningRun.id } : {}
11206
+ };
11207
+ }
11208
+ const currentIndex = snapshot.runs.findIndex((item) => item.id === run.id);
11209
+ if (currentIndex >= 0) {
11210
+ snapshot.runs.splice(currentIndex, 1);
11211
+ const firstQueuedIndex = snapshot.runs.findIndex(
11212
+ (item) => item.status === "queued"
11213
+ );
11214
+ snapshot.runs.splice(
11215
+ firstQueuedIndex === -1 ? snapshot.runs.length : firstQueuedIndex,
11216
+ 0,
11217
+ run
11218
+ );
11219
+ }
11220
+ await this.deps.store.writeSnapshot(conversationId, snapshot);
11221
+ const event = await this.deps.appendEvent(conversationId, {
11222
+ type: "run.queue_promoted",
11223
+ message_id: user?.id,
11224
+ run_id: run.id,
11225
+ payload: {
11226
+ run,
11227
+ queued_run: run,
11228
+ mode: "guided_interrupt",
11229
+ guided_after_run_id: runningRun?.id
11230
+ }
11231
+ });
11232
+ await this.deps.metadata.persistConversationStats(conversationId, snapshot);
11233
+ return {
11234
+ conversation_id: manifest.id,
11235
+ queued_run: { id: run.id, status: run.status },
11236
+ last_event_seq: event.seq,
11237
+ ...runningRun ? { runningRunId: runningRun.id } : {}
11238
+ };
11239
+ }
11240
+ async cancelQueuedRunLocked(conversationId, runId) {
11241
+ const manifest = await this.deps.store.readRunnableManifest(conversationId);
11242
+ const snapshot = await this.deps.store.readSnapshot(conversationId);
11243
+ const run = snapshot.runs.find((item) => item.id === runId);
11244
+ if (!run) {
11245
+ throw new LinkHttpError(404, "run_not_found", "Run was not found");
11246
+ }
11247
+ if (run.status === "running") {
11248
+ throw new LinkHttpError(
11249
+ 409,
11250
+ "queued_run_already_started",
11251
+ "Queued run has already started"
11252
+ );
11253
+ }
11254
+ if (run.status !== "queued") {
11255
+ return {
11256
+ conversation_id: manifest.id,
11257
+ queued_run: { id: run.id, status: run.status },
11258
+ last_event_seq: manifest.last_event_seq
11259
+ };
11260
+ }
11261
+ const cancelledAt = (/* @__PURE__ */ new Date()).toISOString();
11262
+ run.status = "cancelled";
11263
+ run.completed_at = cancelledAt;
11264
+ run.error_message = "cancelled while queued";
11265
+ const user = snapshot.messages.find(
11266
+ (message) => message.id === run.trigger_message_id
11267
+ );
11268
+ if (user) {
11269
+ user.status = "cancelled";
11270
+ user.updated_at = cancelledAt;
11271
+ user.hermes = {
11272
+ ...user.hermes ?? {},
11273
+ queue_cancelled_at: cancelledAt
11274
+ };
11275
+ }
11276
+ await this.deps.store.writeSnapshot(conversationId, snapshot);
11277
+ let eventSeq = manifest.last_event_seq;
11278
+ if (user) {
11279
+ const userEvent = await this.deps.appendEvent(conversationId, {
11280
+ type: "message.completed",
11281
+ message_id: user.id,
11282
+ run_id: run.id,
11283
+ payload: { message: user, cancelled: true }
11284
+ });
11285
+ eventSeq = userEvent.seq;
11286
+ }
11287
+ const runEvent = await this.deps.appendEvent(conversationId, {
11288
+ type: "run.cancelled",
11289
+ run_id: run.id,
11290
+ payload: { run, reason: "cancelled while queued" }
11291
+ });
11292
+ await this.deps.metadata.persistConversationStats(conversationId, snapshot);
11293
+ return {
11294
+ conversation_id: manifest.id,
11295
+ queued_run: { id: run.id, status: run.status },
11296
+ last_event_seq: Math.max(eventSeq, runEvent.seq)
11297
+ };
11298
+ }
10850
11299
  async resetConversationContextLocked(input) {
10851
11300
  if (hasRunningRuns(input.snapshot)) {
10852
11301
  return this.appendCommandResultLocked({
@@ -11133,7 +11582,7 @@ function projectAgentEvent(input) {
11133
11582
  summary,
11134
11583
  args
11135
11584
  });
11136
- const detail = status === "failed" ? readErrorMessage(input.payload) ?? actionSummary ?? void 0 : actionSummary ?? void 0;
11585
+ const detail = status === "failed" ? readErrorMessage2(input.payload) ?? actionSummary ?? void 0 : actionSummary ?? void 0;
11137
11586
  const subtitle = actionSummary ?? (status === "running" ? `\u6B63\u5728\u8C03\u7528 ${name}` : status === "completed" ? `${name} \u5DF2\u5B8C\u6210` : `${name} \u6267\u884C\u5931\u8D25`);
11138
11587
  return {
11139
11588
  id,
@@ -11430,7 +11879,7 @@ function stableStringify(value) {
11430
11879
  function hashAgentEventKey(value) {
11431
11880
  return createHash3("sha256").update(value).digest("hex").slice(0, 16);
11432
11881
  }
11433
- function readErrorMessage(payload) {
11882
+ function readErrorMessage2(payload) {
11434
11883
  if (typeof payload.error === "string" && payload.error.trim()) {
11435
11884
  return payload.error.trim();
11436
11885
  }
@@ -11785,7 +12234,7 @@ function hydrateAgentEventBlocks(blocks, agentEvents) {
11785
12234
  // src/conversations/conversation-store.ts
11786
12235
  import {
11787
12236
  appendFile as appendFile2,
11788
- mkdir as mkdir9,
12237
+ mkdir as mkdir10,
11789
12238
  readdir as readdir5,
11790
12239
  readFile as readFile7,
11791
12240
  rm as rm5,
@@ -11798,7 +12247,7 @@ var ConversationStore = class {
11798
12247
  }
11799
12248
  paths;
11800
12249
  async ensureConversationsDir() {
11801
- await mkdir9(this.paths.conversationsDir, { recursive: true, mode: 448 });
12250
+ await mkdir10(this.paths.conversationsDir, { recursive: true, mode: 448 });
11802
12251
  }
11803
12252
  async listConversationIds() {
11804
12253
  await this.ensureConversationsDir();
@@ -11813,7 +12262,7 @@ var ConversationStore = class {
11813
12262
  return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
11814
12263
  }
11815
12264
  async createConversation(manifest, snapshot = createEmptySnapshot2()) {
11816
- await mkdir9(this.conversationDir(manifest.id), {
12265
+ await mkdir10(this.conversationDir(manifest.id), {
11817
12266
  recursive: true,
11818
12267
  mode: 448
11819
12268
  });
@@ -11853,7 +12302,7 @@ var ConversationStore = class {
11853
12302
  conversation_id: conversationId,
11854
12303
  created_at: now
11855
12304
  };
11856
- await mkdir9(this.conversationDir(conversationId), {
12305
+ await mkdir10(this.conversationDir(conversationId), {
11857
12306
  recursive: true,
11858
12307
  mode: 448
11859
12308
  });
@@ -11949,7 +12398,7 @@ function isNodeError9(error, code) {
11949
12398
  }
11950
12399
 
11951
12400
  // src/conversations/hermes-session-sync.ts
11952
- import { randomUUID as randomUUID8 } from "crypto";
12401
+ import { randomUUID as randomUUID9 } from "crypto";
11953
12402
  import { readdir as readdir7, readFile as readFile9, stat as stat9 } from "fs/promises";
11954
12403
  import path16 from "path";
11955
12404
 
@@ -12391,7 +12840,7 @@ async function syncHermesSessionsIntoConversations(paths, logger, options = {})
12391
12840
  result.skipped_existing += 1;
12392
12841
  continue;
12393
12842
  }
12394
- const imported = await importHermesSession({
12843
+ const importedConversationId = await importHermesSession({
12395
12844
  paths,
12396
12845
  store,
12397
12846
  logger,
@@ -12401,9 +12850,9 @@ async function syncHermesSessionsIntoConversations(paths, logger, options = {})
12401
12850
  profile: candidate.profileName,
12402
12851
  message: error instanceof Error ? error.message : String(error)
12403
12852
  });
12404
- return false;
12853
+ return null;
12405
12854
  });
12406
- if (imported) {
12855
+ if (importedConversationId) {
12407
12856
  result.imported_count += 1;
12408
12857
  for (const sessionId of candidateSessionIds) {
12409
12858
  knownHermesSessions.sessionIds.add(sessionId);
@@ -12417,6 +12866,80 @@ async function syncHermesSessionsIntoConversations(paths, logger, options = {})
12417
12866
  }
12418
12867
  return result;
12419
12868
  }
12869
+ async function syncHermesCronSessionIntoConversations(paths, logger, input) {
12870
+ const jobId = input.jobId.trim();
12871
+ if (!jobId) {
12872
+ return { conversation_id: null, imported: false, reprojected: false };
12873
+ }
12874
+ const store = new ConversationStore(paths);
12875
+ const knownHermesSessions = await readKnownHermesSessions(store);
12876
+ const profileName = input.profileName.trim() || DEFAULT_PROFILE_NAME;
12877
+ const profileDir = resolveHermesProfileDir(profileName);
12878
+ const dbPath = path16.join(profileDir, "state.db");
12879
+ const sessions = await listProfileSessionsByIdPrefix(
12880
+ dbPath,
12881
+ `cron_${jobId}_`
12882
+ ).catch((error) => {
12883
+ void logger.warn("hermes_cron_session_query_failed", {
12884
+ profile: profileName,
12885
+ job_id: jobId,
12886
+ error: error instanceof Error ? error.message : String(error)
12887
+ });
12888
+ return [];
12889
+ });
12890
+ const candidate = selectCronSessionCandidate(
12891
+ sessions.filter((session) => !isDeletedSession(session) && !isHiddenSession(session)).map((session) => ({ profileName, profileDir, dbPath, session })),
12892
+ input.runAt
12893
+ );
12894
+ if (!candidate) {
12895
+ return { conversation_id: null, imported: false, reprojected: false };
12896
+ }
12897
+ const knownConversationIds = findKnownConversationIdsForCandidate(
12898
+ knownHermesSessions,
12899
+ candidate
12900
+ );
12901
+ if (knownConversationIds.length > 0) {
12902
+ const reprojected = await mergeExistingHermesConversation({
12903
+ paths,
12904
+ store,
12905
+ logger,
12906
+ candidate,
12907
+ conversationIds: knownConversationIds
12908
+ });
12909
+ const canonical = await findCanonicalConversationForCandidate(
12910
+ store,
12911
+ knownConversationIds
12912
+ );
12913
+ return {
12914
+ conversation_id: canonical?.conversationId ?? knownConversationIds[0] ?? null,
12915
+ hermes_session_id: candidate.session.id,
12916
+ imported: false,
12917
+ reprojected
12918
+ };
12919
+ }
12920
+ if (lineageSessionIds(candidate).some(
12921
+ (sessionId) => knownHermesSessions.sessionIds.has(sessionId)
12922
+ )) {
12923
+ return {
12924
+ conversation_id: null,
12925
+ hermes_session_id: candidate.session.id,
12926
+ imported: false,
12927
+ reprojected: false
12928
+ };
12929
+ }
12930
+ const conversationId = await importHermesSession({
12931
+ paths,
12932
+ store,
12933
+ logger,
12934
+ candidate
12935
+ });
12936
+ return {
12937
+ conversation_id: conversationId,
12938
+ hermes_session_id: candidate.session.id,
12939
+ imported: conversationId != null,
12940
+ reprojected: false
12941
+ };
12942
+ }
12420
12943
  async function importHermesSession(input) {
12421
12944
  const { paths, store, logger, candidate } = input;
12422
12945
  const profile = await resolveConversationProfileTarget(
@@ -12442,7 +12965,7 @@ async function importHermesSession(input) {
12442
12965
  }),
12443
12966
  runs: []
12444
12967
  };
12445
- const title = lineageTitle(candidate) ?? readString9(candidate.session, "title") ?? firstUserText(snapshot);
12968
+ const title = deriveHermesConversationTitle(candidate, snapshot);
12446
12969
  const importedStats = buildImportedHermesStats({
12447
12970
  candidate,
12448
12971
  snapshot,
@@ -12509,7 +13032,7 @@ async function importHermesSession(input) {
12509
13032
  paths,
12510
13033
  toStatsIndexRecord(await store.readManifest(conversationId), stats)
12511
13034
  );
12512
- return true;
13035
+ return conversationId;
12513
13036
  }
12514
13037
  async function mergeExistingHermesConversation(input) {
12515
13038
  const conversations = await readExistingHermesConversations(
@@ -12618,7 +13141,17 @@ function lineageSessionIds(candidate) {
12618
13141
  ]);
12619
13142
  }
12620
13143
  function lineageTitle(candidate) {
12621
- return candidate.session._lineage_title ?? stripCompressionTitleSuffix2(readString9(candidate.session, "title") ?? "");
13144
+ const explicitLineageTitle = normalizeOptionalTitle(
13145
+ candidate.session._lineage_title
13146
+ );
13147
+ if (explicitLineageTitle) {
13148
+ return explicitLineageTitle;
13149
+ }
13150
+ const sessionTitle = readString9(candidate.session, "title") ?? "";
13151
+ return isDefaultConversationTitle(sessionTitle) ? void 0 : stripCompressionTitleSuffix2(sessionTitle);
13152
+ }
13153
+ function deriveHermesConversationTitle(candidate, snapshot) {
13154
+ return lineageTitle(candidate) ?? normalizeHermesSourceTitle(readString9(candidate.session, "title")) ?? firstUserText(snapshot);
12622
13155
  }
12623
13156
  function lineageManifestPatch(candidate) {
12624
13157
  const sessionIds = lineageSessionIds(candidate);
@@ -12771,7 +13304,7 @@ function mergeHermesLineageIntoManifest(input) {
12771
13304
  profile: input.manifest.profile ?? input.manifest.profile_name_snapshot ?? input.profileName,
12772
13305
  updated_at: latestTimestamp(input.manifest.updated_at, input.updatedAt)
12773
13306
  };
12774
- const title = lineageTitle(input.candidate);
13307
+ const title = deriveHermesConversationTitle(input.candidate, input.snapshot);
12775
13308
  if (title && canSyncHermesTitle(input.manifest)) {
12776
13309
  nextBase.title = normalizeTitle(title);
12777
13310
  nextBase.title_source = "hermes";
@@ -13338,6 +13871,15 @@ async function discoverHermesProfileNames() {
13338
13871
  });
13339
13872
  }
13340
13873
  async function listProfileSessions(dbPath) {
13874
+ return listProfileSessionsWhere(dbPath);
13875
+ }
13876
+ async function listProfileSessionsByIdPrefix(dbPath, sessionIdPrefix) {
13877
+ return listProfileSessionsWhere(dbPath, {
13878
+ whereSql: "WHERE s.id LIKE ?",
13879
+ params: [`${sessionIdPrefix}%`]
13880
+ });
13881
+ }
13882
+ async function listProfileSessionsWhere(dbPath, filter = null) {
13341
13883
  if (!await isFile(dbPath)) {
13342
13884
  return [];
13343
13885
  }
@@ -13362,14 +13904,54 @@ async function listProfileSessions(dbPath) {
13362
13904
  `
13363
13905
  SELECT ${selectColumns}, ${lastActiveSql}
13364
13906
  FROM sessions s
13907
+ ${filter?.whereSql ?? ""}
13365
13908
  ORDER BY last_active DESC
13366
13909
  `
13367
- ).all();
13910
+ ).all(...filter?.params ?? []);
13368
13911
  return projectCompressionTips(rows);
13369
13912
  } finally {
13370
13913
  db?.close();
13371
13914
  }
13372
13915
  }
13916
+ function selectCronSessionCandidate(candidates, runAt) {
13917
+ if (candidates.length === 0) {
13918
+ return null;
13919
+ }
13920
+ const targetTime = Date.parse(runAt ?? "");
13921
+ return [...candidates].sort((left, right) => {
13922
+ const leftTime = cronCandidateTime(left);
13923
+ const rightTime = cronCandidateTime(right);
13924
+ if (!Number.isNaN(targetTime)) {
13925
+ const leftFuture = leftTime > targetTime + 6e4 ? 1 : 0;
13926
+ const rightFuture = rightTime > targetTime + 6e4 ? 1 : 0;
13927
+ if (leftFuture !== rightFuture) {
13928
+ return leftFuture - rightFuture;
13929
+ }
13930
+ const leftDistance = Number.isNaN(leftTime) ? Number.POSITIVE_INFINITY : Math.abs(leftTime - targetTime);
13931
+ const rightDistance = Number.isNaN(rightTime) ? Number.POSITIVE_INFINITY : Math.abs(rightTime - targetTime);
13932
+ if (leftDistance !== rightDistance) {
13933
+ return leftDistance - rightDistance;
13934
+ }
13935
+ }
13936
+ return rightTime - leftTime;
13937
+ })[0] ?? null;
13938
+ }
13939
+ function cronCandidateTime(candidate) {
13940
+ return hermesTimestampMs(candidate.session.last_active) ?? hermesTimestampMs(candidate.session.ended_at) ?? hermesTimestampMs(candidate.session.started_at) ?? Number.NaN;
13941
+ }
13942
+ function hermesTimestampMs(value) {
13943
+ const seconds = readNumber2(value);
13944
+ return seconds == null ? null : seconds * 1e3;
13945
+ }
13946
+ async function findCanonicalConversationForCandidate(store, conversationIds) {
13947
+ const conversations = await readExistingHermesConversations(
13948
+ store,
13949
+ conversationIds
13950
+ );
13951
+ return selectCanonicalHermesConversation(
13952
+ conversations.filter((item) => item.manifest.status === "active")
13953
+ );
13954
+ }
13373
13955
  function appendHermesRawMessage(message, row) {
13374
13956
  const rows = readHermesRawMessageRows(message.raw);
13375
13957
  message.raw = rows.length === 0 ? {
@@ -13719,7 +14301,7 @@ function toLinkMessage(input) {
13719
14301
  const sessionId = readString9(input.message, "session_id") ?? input.sessionId;
13720
14302
  const createdAt = isoFromHermesTime(input.message.timestamp) ?? new Date(Date.now() + input.index).toISOString();
13721
14303
  return {
13722
- id: `msg_${randomUUID8().replaceAll("-", "")}`,
14304
+ id: `msg_${randomUUID9().replaceAll("-", "")}`,
13723
14305
  schema_version: 1,
13724
14306
  conversation_id: input.conversationId,
13725
14307
  role,
@@ -13771,6 +14353,17 @@ function normalizeTitle(value) {
13771
14353
  const normalized = value?.replace(/\s+/gu, " ").trim();
13772
14354
  return normalized || DEFAULT_CONVERSATION_TITLE;
13773
14355
  }
14356
+ function normalizeOptionalTitle(value) {
14357
+ const normalized = value?.replace(/\s+/gu, " ").trim();
14358
+ return normalized ? normalized : void 0;
14359
+ }
14360
+ function normalizeHermesSourceTitle(value) {
14361
+ const normalized = normalizeOptionalTitle(value);
14362
+ if (!normalized || isDefaultConversationTitle(normalized)) {
14363
+ return void 0;
14364
+ }
14365
+ return normalized;
14366
+ }
13774
14367
  function canSyncHermesTitle(manifest) {
13775
14368
  return manifest.title_source !== "manual" && manifest.title_source !== "generated" && manifest.title_source !== "temporary_user_message" && manifest.title_source !== "temporary_fallback";
13776
14369
  }
@@ -13891,7 +14484,7 @@ async function isFile(filePath) {
13891
14484
  });
13892
14485
  }
13893
14486
  function createConversationId() {
13894
- return `conv_${randomUUID8().replaceAll("-", "")}`;
14487
+ return `conv_${randomUUID9().replaceAll("-", "")}`;
13895
14488
  }
13896
14489
  function isoFromHermesTime(value) {
13897
14490
  const numeric = readNumber2(value);
@@ -15290,71 +15883,6 @@ function compactProcessOutput(value) {
15290
15883
  return compact || null;
15291
15884
  }
15292
15885
 
15293
- // src/identity/identity.ts
15294
- import { generateKeyPairSync, randomUUID as randomUUID9, sign } from "crypto";
15295
- import { mkdir as mkdir10, chmod as chmod2 } from "fs/promises";
15296
- import { z } from "zod";
15297
- var linkIdentitySchema = z.object({
15298
- install_id: z.string().min(1),
15299
- link_id: z.string().min(1).nullable().optional(),
15300
- public_key_pem: z.string().min(1),
15301
- private_key_pem: z.string().min(1),
15302
- created_at: z.string().min(1),
15303
- updated_at: z.string().min(1)
15304
- });
15305
- async function loadIdentity(paths = resolveRuntimePaths()) {
15306
- const value = await readJsonFile(paths.identityFile);
15307
- if (value === null) {
15308
- return null;
15309
- }
15310
- return linkIdentitySchema.parse(value);
15311
- }
15312
- async function ensureIdentity(paths = resolveRuntimePaths()) {
15313
- const existing = await loadIdentity(paths);
15314
- if (existing) {
15315
- return existing;
15316
- }
15317
- await mkdir10(paths.homeDir, { recursive: true, mode: 448 });
15318
- await chmod2(paths.homeDir, 448).catch(() => void 0);
15319
- const { publicKey, privateKey } = generateKeyPairSync("ed25519");
15320
- const now = (/* @__PURE__ */ new Date()).toISOString();
15321
- const identity = {
15322
- install_id: `install_${randomUUID9().replaceAll("-", "")}`,
15323
- link_id: null,
15324
- public_key_pem: publicKey.export({ type: "spki", format: "pem" }).toString(),
15325
- private_key_pem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
15326
- created_at: now,
15327
- updated_at: now
15328
- };
15329
- await writeJsonFile(paths.identityFile, identity);
15330
- return identity;
15331
- }
15332
- async function saveAssignedLinkId(linkId, paths = resolveRuntimePaths()) {
15333
- const identity = await ensureIdentity(paths);
15334
- const next = {
15335
- ...identity,
15336
- link_id: linkId,
15337
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
15338
- };
15339
- await writeJsonFile(paths.identityFile, next);
15340
- return next;
15341
- }
15342
- function signRelayNonce(identity, nonce) {
15343
- return signIdentityPayload(identity, nonce);
15344
- }
15345
- function signIdentityPayload(identity, payload) {
15346
- const signature = sign(null, Buffer.from(payload, "utf8"), identity.private_key_pem);
15347
- return signature.toString("base64url");
15348
- }
15349
- function getIdentityStatus(identity) {
15350
- return {
15351
- installId: identity.install_id,
15352
- linkId: identity.link_id ?? null,
15353
- hasPrivateKey: identity.private_key_pem.trim().length > 0,
15354
- publicKeyPem: identity.public_key_pem
15355
- };
15356
- }
15357
-
15358
15886
  // src/conversations/hermes-sse.ts
15359
15887
  async function* parseSseResponse(response) {
15360
15888
  if (!response.body) {
@@ -16036,7 +16564,7 @@ function normalizeHermesStreamEvent(event) {
16036
16564
  ...event.payload,
16037
16565
  type: "run.failed",
16038
16566
  error: {
16039
- message: readErrorMessage2(event.payload) ?? readDelta(event.payload) ?? "Hermes run failed"
16567
+ message: readErrorMessage3(event.payload) ?? readDelta(event.payload) ?? "Hermes run failed"
16040
16568
  }
16041
16569
  }
16042
16570
  };
@@ -16112,11 +16640,24 @@ function normalizeHermesResponseEvent(event) {
16112
16640
  } : null;
16113
16641
  }
16114
16642
  case "response.created":
16115
- return null;
16643
+ return normalizeResponseCreated(event);
16116
16644
  default:
16117
16645
  return null;
16118
16646
  }
16119
16647
  }
16648
+ function normalizeResponseCreated(event) {
16649
+ const response = toRecord12(event.payload.response ?? event.payload);
16650
+ const responseId = readString14(response, "id") ?? readString14(event.payload, "id");
16651
+ return responseId ? {
16652
+ ...event,
16653
+ payloadType: "response.created",
16654
+ payload: {
16655
+ type: "response.created",
16656
+ response_id: responseId,
16657
+ response
16658
+ }
16659
+ } : null;
16660
+ }
16120
16661
  function normalizeResponseOutputItemAdded(event) {
16121
16662
  const item = toRecord12(event.payload.item);
16122
16663
  if (readString14(item, "type") !== "function_call") {
@@ -16213,7 +16754,7 @@ function normalizeStreamingTextDelta(currentText, nextChunk) {
16213
16754
  }
16214
16755
  return nextChunk;
16215
16756
  }
16216
- function readErrorMessage2(payload) {
16757
+ function readErrorMessage3(payload) {
16217
16758
  if (typeof payload.error === "string" && payload.error.trim()) {
16218
16759
  return payload.error.trim();
16219
16760
  }
@@ -16277,7 +16818,7 @@ function isTopLevelErrorEvent(event) {
16277
16818
  if (type.startsWith("tool.")) {
16278
16819
  return false;
16279
16820
  }
16280
- return type === "error" || type === "run.error" || event.eventName === "error" || Boolean(readErrorMessage2(event.payload));
16821
+ return type === "error" || type === "run.error" || event.eventName === "error" || Boolean(readErrorMessage3(event.payload));
16281
16822
  }
16282
16823
  function readChatCompletionDelta(payload) {
16283
16824
  const choice = readFirstChoice(payload);
@@ -16863,12 +17404,27 @@ var ConversationRunLifecycle = class {
16863
17404
  this.deps.scheduleTitleRefresh(input.conversationId);
16864
17405
  }
16865
17406
  async handleNormalizedHermesEvent(input) {
17407
+ if (input.event.payloadType === "response.created") {
17408
+ const responseId = readResponseId(input.event.payload);
17409
+ if (responseId) {
17410
+ await this.updateRun(input.conversationId, input.runId, {
17411
+ hermes_response_id: responseId
17412
+ });
17413
+ }
17414
+ await this.persistHermesEvent(input.conversationId, input.runId, input.event);
17415
+ return false;
17416
+ }
16866
17417
  if (input.event.payloadType === "run.completed") {
16867
17418
  if (input.cronJobIdsBeforeRun) {
17419
+ const snapshot = await this.deps.readSnapshot(input.conversationId).catch(() => null);
17420
+ const run = snapshot?.runs.find((item) => item.id === input.runId) ?? null;
17421
+ const manifest = await this.deps.readActiveManifest(input.conversationId).catch(() => null);
16868
17422
  await this.bindNewCronJobsCreatedByRun({
16869
17423
  profileName: input.profileName,
16870
17424
  conversationId: input.conversationId,
16871
- beforeJobIds: input.cronJobIdsBeforeRun
17425
+ beforeJobIds: input.cronJobIdsBeforeRun,
17426
+ ownerAccountId: run?.owner_account_id ?? manifest?.owner_account_id,
17427
+ ownerAppInstanceId: run?.owner_app_instance_id ?? manifest?.owner_app_instance_id
16872
17428
  });
16873
17429
  }
16874
17430
  await this.deps.syncCronDeliveries().catch((error) => {
@@ -16926,7 +17482,7 @@ var ConversationRunLifecycle = class {
16926
17482
  await this.failRun(
16927
17483
  input.conversationId,
16928
17484
  input.runId,
16929
- readErrorMessage2(input.event.payload) ?? "Hermes run failed",
17485
+ readErrorMessage3(input.event.payload) ?? "Hermes run failed",
16930
17486
  input.event
16931
17487
  );
16932
17488
  return true;
@@ -17176,8 +17732,11 @@ var ConversationRunLifecycle = class {
17176
17732
  const userMessage = input.snapshot.messages.find(
17177
17733
  (message) => message.id === input.run.trigger_message_id
17178
17734
  );
17735
+ const prefix = guidedInterruptInputPrefix(input.run);
17179
17736
  if (!userMessage || !userMessage.parts.some(isVoicePart)) {
17180
- return input.fallbackInput;
17737
+ return prefix ? `${prefix}
17738
+
17739
+ ${input.fallbackInput}` : input.fallbackInput;
17181
17740
  }
17182
17741
  const content = messageText(userMessage);
17183
17742
  const voiceLines = [];
@@ -17209,11 +17768,16 @@ ${attachmentLines.join("\n")}`
17209
17768
  );
17210
17769
  }
17211
17770
  if (sections.length === 0) {
17212
- return content;
17771
+ return prefix ? `${prefix}
17772
+
17773
+ ${content}` : content;
17213
17774
  }
17214
- return `${content ? `${content}
17775
+ const resolved = `${content ? `${content}
17215
17776
 
17216
17777
  ` : ""}${sections.join("\n\n")}`;
17778
+ return prefix ? `${prefix}
17779
+
17780
+ ${resolved}` : resolved;
17217
17781
  }
17218
17782
  async updateRun(conversationId, runId, patch) {
17219
17783
  return this.deps.withConversationLock(
@@ -17634,6 +18198,13 @@ ${details.join("\n")}` : emptyHermesResponseMessage();
17634
18198
  ...source ? { raw: { format: "hermes-run-event", payload: source.rawPayload } } : {}
17635
18199
  });
17636
18200
  await this.deps.persistConversationStats(conversationId, snapshot);
18201
+ void this.reportRunNotification({
18202
+ conversationId,
18203
+ run,
18204
+ assistant,
18205
+ eventKind: "run_completed",
18206
+ occurredAt: completedAt
18207
+ });
17637
18208
  }
17638
18209
  async failRunLocked(conversationId, runId, message, source) {
17639
18210
  const snapshot = await this.deps.readSnapshot(conversationId).catch(() => null);
@@ -17647,7 +18218,7 @@ ${details.join("\n")}` : emptyHermesResponseMessage();
17647
18218
  run.status = "failed";
17648
18219
  run.completed_at = (/* @__PURE__ */ new Date()).toISOString();
17649
18220
  run.error_message = message;
17650
- run.error_detail = source ? readErrorMessage2(source.payload) ?? void 0 : void 0;
18221
+ run.error_detail = source ? readErrorMessage3(source.payload) ?? void 0 : void 0;
17651
18222
  const visibleMessage = formatFailureMessage(message, run.error_detail);
17652
18223
  const usage = readUsage(source?.payload);
17653
18224
  if (usage) {
@@ -17685,12 +18256,56 @@ ${details.join("\n")}` : emptyHermesResponseMessage();
17685
18256
  ...source ? { raw: { format: "hermes-run-event", payload: source.rawPayload } } : {}
17686
18257
  });
17687
18258
  await this.deps.persistConversationStats(conversationId, snapshot);
18259
+ void this.reportRunNotification({
18260
+ conversationId,
18261
+ run,
18262
+ assistant,
18263
+ eventKind: "run_failed",
18264
+ occurredAt: run.completed_at,
18265
+ fallbackPreview: visibleMessage
18266
+ });
17688
18267
  void this.deps.logger.warn("conversation_run_failed", {
17689
18268
  conversation_id: conversationId,
17690
18269
  run_id: runId,
17691
18270
  error: message
17692
18271
  });
17693
18272
  }
18273
+ async reportRunNotification(input) {
18274
+ const manifest = await this.deps.readActiveManifest(input.conversationId).catch(() => null);
18275
+ if (!manifest) {
18276
+ return;
18277
+ }
18278
+ const accountId = input.run.owner_account_id ?? manifest.owner_account_id;
18279
+ if (!accountId) {
18280
+ return;
18281
+ }
18282
+ await reportNotificationEventToServer({
18283
+ paths: this.deps.paths,
18284
+ logger: this.deps.logger,
18285
+ event: {
18286
+ sourceEventId: runNotificationSourceEventId(
18287
+ input.conversationId,
18288
+ input.run.id,
18289
+ input.eventKind
18290
+ ),
18291
+ eventKind: input.eventKind,
18292
+ conversationId: input.conversationId,
18293
+ conversationTitle: manifest.title,
18294
+ accountId,
18295
+ messageId: input.assistant?.id ?? input.run.assistant_message_id,
18296
+ runId: input.run.id,
18297
+ bodyPreview: previewText2(input.assistant) ?? input.fallbackPreview,
18298
+ occurredAt: input.occurredAt
18299
+ }
18300
+ }).catch((error) => {
18301
+ void this.deps.logger.warn("notification_event_report_failed", {
18302
+ conversation_id: input.conversationId,
18303
+ run_id: input.run.id,
18304
+ event_kind: input.eventKind,
18305
+ error: error instanceof Error ? error.message : String(error)
18306
+ });
18307
+ });
18308
+ }
17694
18309
  async cancelRunLocked(conversationId, runId, options) {
17695
18310
  const manifest = await this.deps.readRunnableManifest(conversationId);
17696
18311
  const snapshot = await this.deps.readSnapshot(conversationId);
@@ -17721,6 +18336,13 @@ ${details.join("\n")}` : emptyHermesResponseMessage();
17721
18336
  });
17722
18337
  }
17723
18338
  }
18339
+ if (!run.hermes_response_id) {
18340
+ void this.deps.logger.warn("interrupted_response_id_missing", {
18341
+ conversation_id: conversationId,
18342
+ run_id: runId,
18343
+ reason: options.reason
18344
+ });
18345
+ }
17724
18346
  const cancelledAt = (/* @__PURE__ */ new Date()).toISOString();
17725
18347
  run.status = "cancelled";
17726
18348
  run.completed_at = cancelledAt;
@@ -17806,7 +18428,9 @@ ${details.join("\n")}` : emptyHermesResponseMessage();
17806
18428
  profileName,
17807
18429
  conversationId: input.conversationId,
17808
18430
  beforeJobIds: input.beforeJobIds,
17809
- jobs
18431
+ jobs,
18432
+ ownerAccountId: input.ownerAccountId,
18433
+ ownerAppInstanceId: input.ownerAppInstanceId
17810
18434
  });
17811
18435
  }
17812
18436
  };
@@ -17824,7 +18448,19 @@ function buildRunInstructions(run, deliveryStagingDir) {
17824
18448
  "Current runtime selected by Hermes Link:",
17825
18449
  `- Model: ${run.model ?? "hermes-agent"}`,
17826
18450
  `- Provider: ${run.provider ?? "unknown"}`,
17827
- "If the user asks what model or provider you are currently using, answer from this runtime selection instead of inferring from earlier conversation history."
18451
+ "If the user asks what model or provider you are currently using, answer from this runtime selection instead of inferring from earlier conversation history.",
18452
+ ...run.queue_mode === "guided_interrupt" ? ["", "Guided interrupt handling:", guidedInterruptNote()] : []
18453
+ ].join("\n");
18454
+ }
18455
+ function guidedInterruptInputPrefix(run) {
18456
+ return run.queue_mode === "guided_interrupt" ? guidedInterruptNote() : "";
18457
+ }
18458
+ function guidedInterruptNote() {
18459
+ return [
18460
+ "System note from Hermes Link: The previous assistant turn was interrupted by the user.",
18461
+ "Treat the following user message as the controlling instruction for this turn.",
18462
+ "Do not continue, retry, or complete the interrupted task or its tool calls unless the following user message explicitly asks you to do so.",
18463
+ "Any partial tool results from the interrupted turn are context only, not an instruction to keep working on that task."
17828
18464
  ].join("\n");
17829
18465
  }
17830
18466
  function appendMediaImportFailureNotice(message) {
@@ -18088,7 +18724,9 @@ function findPreviousHermesResponseId(snapshot, run) {
18088
18724
  if (!currentProfile) {
18089
18725
  return void 0;
18090
18726
  }
18091
- const candidates = snapshot.runs.filter((item) => item.id !== run.id).filter((item) => item.kind !== "command").filter((item) => item.status === "completed").filter((item) => item.hermes_response_id).filter((item) => item.hermes_session_id === run.hermes_session_id).filter(
18727
+ const candidates = snapshot.runs.filter((item) => item.id !== run.id).filter((item) => item.kind !== "command").filter(
18728
+ (item) => item.status === "completed" || item.status === "cancelled"
18729
+ ).filter((item) => item.hermes_response_id).filter((item) => item.hermes_session_id === run.hermes_session_id).filter(
18092
18730
  (item) => normalizeRunProfileForCompare(
18093
18731
  item.profile_name_snapshot ?? item.profile
18094
18732
  ) === currentProfile
@@ -18171,6 +18809,17 @@ function readStatusErrorMessage(value) {
18171
18809
  function formatUnknownErrorMessage(error) {
18172
18810
  return error instanceof Error ? error.message : String(error);
18173
18811
  }
18812
+ function previewText2(message) {
18813
+ if (!message) {
18814
+ return null;
18815
+ }
18816
+ const text = messageText(message).replace(/<[^>]+>/gu, " ").replace(/\s+/gu, " ").trim();
18817
+ return text ? text.slice(0, 512) : null;
18818
+ }
18819
+ function runNotificationSourceEventId(conversationId, runId, eventKind) {
18820
+ const digest = createHash5("sha256").update(`${conversationId}:${runId}:${eventKind}`).digest("hex").slice(0, 24);
18821
+ return `${conversationId}:${eventKind}:${digest}`;
18822
+ }
18174
18823
  async function closeSseIterator(iterator) {
18175
18824
  if (!iterator.return) {
18176
18825
  return;
@@ -18303,6 +18952,7 @@ var ConversationService = class {
18303
18952
  queries;
18304
18953
  runLifecycle;
18305
18954
  hermesSessionSyncPromise = null;
18955
+ cronDeliverySyncPromise = null;
18306
18956
  async withConversationLock(conversationId, task) {
18307
18957
  const previous = this.conversationLocks.get(conversationId) ?? Promise.resolve();
18308
18958
  let release;
@@ -18413,6 +19063,8 @@ var ConversationService = class {
18413
19063
  profile_uid: profile.profileUid,
18414
19064
  profile_name_snapshot: profile.profileName,
18415
19065
  profile: profile.profileName,
19066
+ owner_account_id: input.accountId,
19067
+ owner_app_instance_id: input.appInstanceId,
18416
19068
  created_at: now,
18417
19069
  updated_at: now,
18418
19070
  last_event_seq: 0
@@ -18466,19 +19118,115 @@ var ConversationService = class {
18466
19118
  return created.id;
18467
19119
  }
18468
19120
  async appendCronDelivery(input) {
18469
- await this.withConversationLock(input.conversationId, async () => {
18470
- const manifest = await this.store.readActiveManifest(input.conversationId);
18471
- const snapshot = await this.store.readSnapshot(input.conversationId);
18472
- if (snapshot.messages.some(
19121
+ if (input.source === "natural_language" && input.conversationId) {
19122
+ return this.appendCronDeliveryToBoundConversation(input);
19123
+ }
19124
+ await syncHermesCronSessionIntoConversations(
19125
+ this.paths,
19126
+ this.logger,
19127
+ {
19128
+ profileName: input.profileName,
19129
+ jobId: input.jobId,
19130
+ runAt: input.runAt
19131
+ }
19132
+ ).catch((error) => {
19133
+ void this.logger.warn("cron_notification_session_sync_failed", {
19134
+ job_id: input.jobId,
19135
+ output_path: input.outputPath,
19136
+ error: error instanceof Error ? error.message : String(error)
19137
+ });
19138
+ return null;
19139
+ });
19140
+ const target = await this.findImportedCronConversation({
19141
+ profileName: input.profileName,
19142
+ jobId: input.jobId,
19143
+ runAt: input.runAt
19144
+ });
19145
+ if (!target) {
19146
+ void this.logger.info("cron_notification_skipped_no_conversation", {
19147
+ profile: input.profileName,
19148
+ job_id: input.jobId,
19149
+ output_path: input.outputPath
19150
+ });
19151
+ return false;
19152
+ }
19153
+ return this.withConversationLock(target.manifest.id, async () => {
19154
+ const manifest = await this.store.readActiveManifest(target.manifest.id);
19155
+ const snapshot = await this.store.readSnapshot(target.manifest.id);
19156
+ const now = (/* @__PURE__ */ new Date()).toISOString();
19157
+ const ownerAccountId = input.accountId ?? manifest.owner_account_id;
19158
+ const ownerAppInstanceId = input.appInstanceId ?? manifest.owner_app_instance_id;
19159
+ const nextManifest = ownerAccountId && ownerAccountId !== manifest.owner_account_id ? {
19160
+ ...manifest,
19161
+ owner_account_id: ownerAccountId,
19162
+ owner_app_instance_id: ownerAppInstanceId,
19163
+ updated_at: now
19164
+ } : manifest;
19165
+ if (nextManifest !== manifest) {
19166
+ await this.store.writeManifest(nextManifest);
19167
+ }
19168
+ const message = selectCronNotificationMessage(snapshot, input.runAt);
19169
+ return this.reportCronNotification({
19170
+ manifest: nextManifest,
19171
+ message,
19172
+ jobId: input.jobId,
19173
+ outputPath: input.outputPath,
19174
+ failed: input.failed === true,
19175
+ occurredAt: input.runAt ?? now,
19176
+ accountId: ownerAccountId,
19177
+ appInstanceId: ownerAppInstanceId,
19178
+ bodyPreview: message != null ? notificationPreviewText(message) : input.content
19179
+ });
19180
+ });
19181
+ }
19182
+ async appendCronDeliveryToBoundConversation(input) {
19183
+ const conversationId = input.conversationId?.trim();
19184
+ if (!conversationId) {
19185
+ return true;
19186
+ }
19187
+ return this.withConversationLock(conversationId, async () => {
19188
+ const manifest = await this.store.readActiveManifest(conversationId);
19189
+ const snapshot = await this.store.readSnapshot(conversationId);
19190
+ const existingMessage = snapshot.messages.find(
18473
19191
  (message2) => message2.hermes?.cron_output_path === input.outputPath
18474
- )) {
18475
- return;
19192
+ );
19193
+ if (existingMessage) {
19194
+ const now2 = (/* @__PURE__ */ new Date()).toISOString();
19195
+ const ownerAccountId2 = input.accountId ?? manifest.owner_account_id;
19196
+ const ownerAppInstanceId2 = input.appInstanceId ?? manifest.owner_app_instance_id;
19197
+ const nextManifest2 = ownerAccountId2 && ownerAccountId2 !== manifest.owner_account_id ? {
19198
+ ...manifest,
19199
+ owner_account_id: ownerAccountId2,
19200
+ owner_app_instance_id: ownerAppInstanceId2,
19201
+ updated_at: now2
19202
+ } : manifest;
19203
+ if (nextManifest2 !== manifest) {
19204
+ await this.store.writeManifest(nextManifest2);
19205
+ }
19206
+ return this.reportCronNotification({
19207
+ manifest: nextManifest2,
19208
+ message: existingMessage,
19209
+ jobId: input.jobId,
19210
+ outputPath: input.outputPath,
19211
+ failed: input.failed === true,
19212
+ occurredAt: input.runAt ?? existingMessage.created_at,
19213
+ accountId: ownerAccountId2,
19214
+ appInstanceId: ownerAppInstanceId2
19215
+ });
18476
19216
  }
18477
19217
  const now = (/* @__PURE__ */ new Date()).toISOString();
18478
19218
  const createdAt = input.runAt ?? now;
18479
19219
  const profileName = normalizeProfileName2(
18480
19220
  manifest.profile_name_snapshot ?? manifest.profile ?? input.profileName
18481
19221
  );
19222
+ const ownerAccountId = input.accountId ?? manifest.owner_account_id;
19223
+ const ownerAppInstanceId = input.appInstanceId ?? manifest.owner_app_instance_id;
19224
+ const nextManifest = ownerAccountId && ownerAccountId !== manifest.owner_account_id ? {
19225
+ ...manifest,
19226
+ owner_account_id: ownerAccountId,
19227
+ owner_app_instance_id: ownerAppInstanceId,
19228
+ updated_at: now
19229
+ } : manifest;
18482
19230
  const message = {
18483
19231
  id: `msg_${randomUUID10().replaceAll("-", "")}`,
18484
19232
  schema_version: 1,
@@ -18510,19 +19258,154 @@ var ConversationService = class {
18510
19258
  output_path: input.outputPath
18511
19259
  }
18512
19260
  }
18513
- };
18514
- snapshot.messages.push(message);
18515
- await this.store.writeSnapshot(manifest.id, snapshot);
18516
- await this.appendEvent(manifest.id, {
18517
- type: "message.created",
18518
- message_id: message.id,
18519
- payload: { message }
19261
+ };
19262
+ snapshot.messages.push(message);
19263
+ await this.store.writeSnapshot(nextManifest.id, snapshot);
19264
+ if (nextManifest !== manifest) {
19265
+ await this.store.writeManifest(nextManifest);
19266
+ }
19267
+ await this.appendEvent(nextManifest.id, {
19268
+ type: "message.created",
19269
+ message_id: message.id,
19270
+ payload: { message }
19271
+ });
19272
+ await this.persistConversationStats(nextManifest.id, snapshot);
19273
+ return this.reportCronNotification({
19274
+ manifest: nextManifest,
19275
+ message,
19276
+ jobId: input.jobId,
19277
+ outputPath: input.outputPath,
19278
+ failed: input.failed === true,
19279
+ occurredAt: createdAt,
19280
+ accountId: ownerAccountId,
19281
+ appInstanceId: ownerAppInstanceId
19282
+ });
19283
+ });
19284
+ }
19285
+ async findImportedCronConversation(input) {
19286
+ const profileName = normalizeProfileName2(input.profileName);
19287
+ const outputAt = Date.parse(input.runAt ?? "");
19288
+ const candidates = [];
19289
+ for (const conversationId of await this.store.listConversationIds()) {
19290
+ const manifest = await this.store.readManifest(conversationId).catch(() => null);
19291
+ if (!manifest || manifest.status !== "active") {
19292
+ continue;
19293
+ }
19294
+ if (normalizeProfileName2(manifest.profile_name_snapshot ?? manifest.profile) !== profileName) {
19295
+ continue;
19296
+ }
19297
+ const sessionIds = conversationHermesSessionIds(manifest);
19298
+ const cronSessionId = sessionIds.find(
19299
+ (sessionId) => isCronSessionIdForJob(sessionId, input.jobId)
19300
+ );
19301
+ if (!cronSessionId) {
19302
+ continue;
19303
+ }
19304
+ candidates.push({
19305
+ manifest,
19306
+ sessionStartedAt: cronSessionStartedAt(cronSessionId, input.jobId),
19307
+ updatedAt: Date.parse(manifest.updated_at)
19308
+ });
19309
+ }
19310
+ if (candidates.length === 0) {
19311
+ return null;
19312
+ }
19313
+ candidates.sort((left, right) => {
19314
+ const leftTime = Number.isNaN(left.sessionStartedAt) ? left.updatedAt : left.sessionStartedAt;
19315
+ const rightTime = Number.isNaN(right.sessionStartedAt) ? right.updatedAt : right.sessionStartedAt;
19316
+ if (!Number.isNaN(outputAt)) {
19317
+ const leftFuture = leftTime > outputAt + 1e4 ? 1 : 0;
19318
+ const rightFuture = rightTime > outputAt + 1e4 ? 1 : 0;
19319
+ if (leftFuture !== rightFuture) {
19320
+ return leftFuture - rightFuture;
19321
+ }
19322
+ }
19323
+ return rightTime - leftTime;
19324
+ });
19325
+ return { manifest: candidates[0].manifest };
19326
+ }
19327
+ async reportCronNotification(input) {
19328
+ const accountId = input.accountId ?? input.manifest.owner_account_id;
19329
+ if (!accountId) {
19330
+ return false;
19331
+ }
19332
+ try {
19333
+ await reportNotificationEventToServer({
19334
+ paths: this.paths,
19335
+ logger: this.logger,
19336
+ event: {
19337
+ sourceEventId: cronNotificationSourceEventId(
19338
+ input.manifest.id,
19339
+ input.jobId,
19340
+ input.outputPath,
19341
+ input.failed ? "cron_failed" : "cron_completed"
19342
+ ),
19343
+ eventKind: input.failed ? "cron_failed" : "cron_completed",
19344
+ conversationId: input.manifest.id,
19345
+ conversationTitle: input.manifest.title,
19346
+ accountId,
19347
+ messageId: input.message?.id,
19348
+ bodyPreview: input.bodyPreview ?? (input.message ? notificationPreviewText(input.message) : null),
19349
+ occurredAt: input.occurredAt
19350
+ }
18520
19351
  });
18521
- await this.persistConversationStats(manifest.id, snapshot);
18522
- });
19352
+ return true;
19353
+ } catch (error) {
19354
+ void this.logger.warn("notification_event_report_failed", {
19355
+ conversation_id: input.manifest.id,
19356
+ job_id: input.jobId,
19357
+ event_kind: input.failed ? "cron_failed" : "cron_completed",
19358
+ error: error instanceof Error ? error.message : String(error)
19359
+ });
19360
+ return false;
19361
+ }
18523
19362
  }
18524
19363
  async syncCronDeliveries() {
18525
- await syncHermesLinkCronDeliveries(this.paths, this, this.logger);
19364
+ if (this.cronDeliverySyncPromise) {
19365
+ return this.cronDeliverySyncPromise;
19366
+ }
19367
+ const task = syncHermesLinkCronDeliveries(this.paths, this, this.logger);
19368
+ this.cronDeliverySyncPromise = task;
19369
+ try {
19370
+ await task;
19371
+ } finally {
19372
+ if (this.cronDeliverySyncPromise === task) {
19373
+ this.cronDeliverySyncPromise = null;
19374
+ }
19375
+ }
19376
+ }
19377
+ async backfillCronOwnership(input) {
19378
+ const accountId = input.accountId?.trim();
19379
+ if (!accountId) {
19380
+ return;
19381
+ }
19382
+ const bindingCount = await backfillHermesLinkCronDeliveryOwner(this.paths, {
19383
+ accountId,
19384
+ appInstanceId: input.appInstanceId
19385
+ });
19386
+ let conversationCount = 0;
19387
+ const now = (/* @__PURE__ */ new Date()).toISOString();
19388
+ for (const conversationId of await this.store.listConversationIds()) {
19389
+ const manifest = await this.store.readManifest(conversationId).catch(() => null);
19390
+ if (!manifest || manifest.status === "deleted_soft" || manifest.owner_account_id || !manifestHasCronSession(manifest)) {
19391
+ continue;
19392
+ }
19393
+ await this.store.writeManifest({
19394
+ ...manifest,
19395
+ owner_account_id: accountId,
19396
+ owner_app_instance_id: input.appInstanceId,
19397
+ updated_at: now
19398
+ });
19399
+ conversationCount += 1;
19400
+ }
19401
+ if (bindingCount > 0 || conversationCount > 0) {
19402
+ void this.logger.info("cron_owner_backfilled", {
19403
+ account_id: accountId,
19404
+ app_instance_id: input.appInstanceId ?? null,
19405
+ bindings: bindingCount,
19406
+ conversations: conversationCount
19407
+ });
19408
+ }
18526
19409
  }
18527
19410
  async syncHermesSessions() {
18528
19411
  if (this.hermesSessionSyncPromise) {
@@ -18819,6 +19702,12 @@ var ConversationService = class {
18819
19702
  }
18820
19703
  return this.cancelRun(conversationId, runId);
18821
19704
  }
19705
+ async guideQueuedRun(conversationId, runId) {
19706
+ return this.orchestration.guideQueuedRun(conversationId, runId);
19707
+ }
19708
+ async cancelQueuedRun(conversationId, runId) {
19709
+ return this.orchestration.cancelQueuedRun(conversationId, runId);
19710
+ }
18822
19711
  async resolveApproval(input) {
18823
19712
  const decision = input.decision;
18824
19713
  if (decision === "once" || decision === "session") {
@@ -19130,9 +20019,76 @@ function findApproval(snapshot, approvalId) {
19130
20019
  }
19131
20020
  return null;
19132
20021
  }
20022
+ function cronNotificationSourceEventId(conversationId, jobId, outputPath, eventKind) {
20023
+ const digest = createHash6("sha256").update(`${conversationId}:${jobId}:${outputPath}:${eventKind}`).digest("hex").slice(0, 24);
20024
+ return `${conversationId}:${eventKind}:${digest}`;
20025
+ }
20026
+ function conversationHermesSessionIds(manifest) {
20027
+ const ids = [
20028
+ manifest.hermes_session_id,
20029
+ ...manifest.hermes_session_ids ?? [],
20030
+ manifest.hermes_lineage?.root_session_id,
20031
+ manifest.hermes_lineage?.current_session_id,
20032
+ ...manifest.hermes_lineage?.session_ids ?? []
20033
+ ].map((id) => id?.trim()).filter((id) => Boolean(id));
20034
+ return [...new Set(ids)];
20035
+ }
20036
+ function manifestHasCronSession(manifest) {
20037
+ return conversationHermesSessionIds(manifest).some(
20038
+ (sessionId) => sessionId.startsWith("cron_")
20039
+ );
20040
+ }
20041
+ function isCronSessionIdForJob(sessionId, jobId) {
20042
+ const normalizedJobId = jobId.trim();
20043
+ return normalizedJobId.length > 0 && sessionId.startsWith(`cron_${normalizedJobId}_`);
20044
+ }
20045
+ function cronSessionStartedAt(sessionId, jobId) {
20046
+ const normalizedJobId = jobId.trim();
20047
+ const prefix = `cron_${normalizedJobId}_`;
20048
+ if (!sessionId.startsWith(prefix)) {
20049
+ return Number.NaN;
20050
+ }
20051
+ const stamp = sessionId.slice(prefix.length);
20052
+ const match = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/u.exec(stamp);
20053
+ if (!match) {
20054
+ return Number.NaN;
20055
+ }
20056
+ const [, year, month, day, hour, minute, second] = match;
20057
+ return new Date(
20058
+ Number(year),
20059
+ Number(month) - 1,
20060
+ Number(day),
20061
+ Number(hour),
20062
+ Number(minute),
20063
+ Number(second)
20064
+ ).getTime();
20065
+ }
20066
+ function selectCronNotificationMessage(snapshot, runAt) {
20067
+ const messages2 = snapshot.messages.filter(
20068
+ (message) => message.role === "assistant"
20069
+ );
20070
+ if (messages2.length === 0) {
20071
+ return null;
20072
+ }
20073
+ const targetTime = Date.parse(runAt ?? "");
20074
+ if (Number.isNaN(targetTime)) {
20075
+ return messages2.at(-1) ?? null;
20076
+ }
20077
+ return [...messages2].sort((left, right) => {
20078
+ const leftTime = Date.parse(left.created_at);
20079
+ const rightTime = Date.parse(right.created_at);
20080
+ const leftDistance = Number.isNaN(leftTime) ? Number.POSITIVE_INFINITY : Math.abs(leftTime - targetTime);
20081
+ const rightDistance = Number.isNaN(rightTime) ? Number.POSITIVE_INFINITY : Math.abs(rightTime - targetTime);
20082
+ return leftDistance - rightDistance;
20083
+ })[0] ?? null;
20084
+ }
20085
+ function notificationPreviewText(message) {
20086
+ const text = messageText(message).replace(/<[^>]+>/gu, " ").replace(/\s+/gu, " ").trim();
20087
+ return text ? text.slice(0, 512) : null;
20088
+ }
19133
20089
 
19134
20090
  // src/security/devices.ts
19135
- import { randomBytes as randomBytes2, randomUUID as randomUUID11, timingSafeEqual, createHash as createHash6 } from "crypto";
20091
+ import { randomBytes as randomBytes2, randomUUID as randomUUID11, timingSafeEqual, createHash as createHash7 } from "crypto";
19136
20092
  var ACCESS_TOKEN_TTL_MS = 15 * 60 * 1e3;
19137
20093
  var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1e3;
19138
20094
  var DEVICE_SEEN_WRITE_INTERVAL_MS = 60 * 60 * 1e3;
@@ -19428,7 +20384,7 @@ function randomToken(prefix) {
19428
20384
  return `${prefix}${randomBytes2(24).toString("base64url")}`;
19429
20385
  }
19430
20386
  function sha256(value) {
19431
- return createHash6("sha256").update(value).digest("hex");
20387
+ return createHash7("sha256").update(value).digest("hex");
19432
20388
  }
19433
20389
  function safeEqual(left, right) {
19434
20390
  const leftBytes = Buffer.from(left);
@@ -19508,7 +20464,8 @@ async function authenticateRequest(ctx, paths) {
19508
20464
  }
19509
20465
  const device = await authenticateDeviceAccessToken(token, paths);
19510
20466
  if (device) {
19511
- return { kind: "device", device };
20467
+ const owner = await readOptionalAppConnectOwner(ctx, paths);
20468
+ return { kind: "device", device, ...owner };
19512
20469
  }
19513
20470
  if (token.startsWith("hpat_")) {
19514
20471
  throw new LinkHttpError(
@@ -19532,6 +20489,31 @@ async function authenticateRequest(ctx, paths) {
19532
20489
  appInstanceId: normalizeAppInstanceId2(claims.app_instance_id)
19533
20490
  };
19534
20491
  }
20492
+ async function readOptionalAppConnectOwner(ctx, paths) {
20493
+ const token = readOptionalHeaderToken(
20494
+ ctx.get("x-hermespilot-app-connect-token")
20495
+ );
20496
+ if (!token) {
20497
+ return {};
20498
+ }
20499
+ try {
20500
+ const [identity, config] = await Promise.all([
20501
+ loadRequiredIdentity(paths),
20502
+ loadConfig(paths)
20503
+ ]);
20504
+ const claims = await verifyAppConnectToken(token, {
20505
+ config,
20506
+ linkId: identity.link_id
20507
+ });
20508
+ return {
20509
+ accountId: claims.sub,
20510
+ scopes: normalizeScopes(claims.scope),
20511
+ appInstanceId: normalizeAppInstanceId2(claims.app_instance_id)
20512
+ };
20513
+ } catch {
20514
+ return {};
20515
+ }
20516
+ }
19535
20517
  async function loadRequiredIdentity(paths) {
19536
20518
  const identity = await loadIdentity(paths);
19537
20519
  if (!identity?.link_id) {
@@ -19547,6 +20529,16 @@ function readBearerToken(value) {
19547
20529
  const token = trimmed.slice(7).trim();
19548
20530
  return token || null;
19549
20531
  }
20532
+ function readOptionalHeaderToken(value) {
20533
+ const trimmed = value.trim();
20534
+ if (!trimmed) {
20535
+ return null;
20536
+ }
20537
+ if (trimmed.toLowerCase().startsWith("bearer ")) {
20538
+ return readBearerToken(trimmed);
20539
+ }
20540
+ return trimmed;
20541
+ }
19550
20542
  function normalizeScopes(value) {
19551
20543
  if (Array.isArray(value)) {
19552
20544
  return value.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
@@ -19979,9 +20971,10 @@ function isExpectedClientDisconnectError(error) {
19979
20971
 
19980
20972
  // src/http/routes/conversations.ts
19981
20973
  function registerConversationRoutes(router, options) {
19982
- const { paths, conversations } = options;
20974
+ const { paths, logger, conversations } = options;
19983
20975
  router.get("/api/v1/conversations", async (ctx) => {
19984
- await authenticateRequest(ctx, paths);
20976
+ const auth = await authenticateRequest(ctx, paths);
20977
+ await prepareConversationListRead(conversations, logger, auth);
19985
20978
  ctx.set("cache-control", "no-store");
19986
20979
  const result = await conversations.listConversationPage({
19987
20980
  limit: readLimit(ctx.query.limit),
@@ -20008,7 +21001,8 @@ function registerConversationRoutes(router, options) {
20008
21001
  };
20009
21002
  });
20010
21003
  router.get("/api/v1/conversations/archived", async (ctx) => {
20011
- await authenticateRequest(ctx, paths);
21004
+ const auth = await authenticateRequest(ctx, paths);
21005
+ await prepareConversationListRead(conversations, logger, auth);
20012
21006
  ctx.set("cache-control", "no-store");
20013
21007
  const result = await conversations.listArchivedConversationPage({
20014
21008
  limit: readLimit(ctx.query.limit),
@@ -20035,14 +21029,16 @@ function registerConversationRoutes(router, options) {
20035
21029
  };
20036
21030
  });
20037
21031
  router.post("/api/v1/conversations", async (ctx) => {
20038
- await authenticateRequest(ctx, paths);
21032
+ const auth = await authenticateRequest(ctx, paths);
20039
21033
  const body = await readJsonBody(ctx.req);
20040
21034
  ctx.status = 201;
20041
21035
  ctx.body = {
20042
21036
  ok: true,
20043
21037
  conversation: await conversations.createConversation({
20044
21038
  title: readString16(body, "title") ?? void 0,
20045
- profileName: readOptionalProfileName(body)
21039
+ profileName: readOptionalProfileName(body),
21040
+ accountId: auth.accountId,
21041
+ appInstanceId: auth.appInstanceId
20046
21042
  })
20047
21043
  };
20048
21044
  });
@@ -20111,7 +21107,7 @@ function registerConversationRoutes(router, options) {
20111
21107
  );
20112
21108
  });
20113
21109
  router.post("/api/v1/conversations/:conversationId/messages", async (ctx) => {
20114
- await authenticateRequest(ctx, paths);
21110
+ const auth = await authenticateRequest(ctx, paths);
20115
21111
  const body = await readJsonBody(ctx.req);
20116
21112
  const content = readString16(body, "content") ?? readString16(body, "text") ?? readString16(body, "input") ?? "";
20117
21113
  const attachments = readMessageAttachments(body.attachments ?? body.blobs);
@@ -20131,7 +21127,9 @@ function registerConversationRoutes(router, options) {
20131
21127
  attachments,
20132
21128
  clientMessageId: readString16(body, "client_message_id") ?? readString16(body, "clientMessageId") ?? void 0,
20133
21129
  idempotencyKey: readHeader(ctx, "idempotency-key") ?? void 0,
20134
- profileName: readOptionalProfileName(body)
21130
+ profileName: readOptionalProfileName(body),
21131
+ accountId: auth.accountId,
21132
+ appInstanceId: auth.appInstanceId
20135
21133
  })
20136
21134
  };
20137
21135
  });
@@ -20185,12 +21183,20 @@ function registerConversationRoutes(router, options) {
20185
21183
  ctx.body = { ok: true };
20186
21184
  });
20187
21185
  router.post("/api/v1/conversations/clear-plans", async (ctx) => {
20188
- await authenticateRequest(ctx, paths);
21186
+ const auth = await authenticateRequest(ctx, paths);
20189
21187
  const body = await readJsonBody(ctx.req);
20190
21188
  const targetStatus = readConversationClearPlanTargetStatus(body);
20191
21189
  const plan = await conversations.prepareClearAllConversationPlan(
20192
21190
  targetStatus
20193
21191
  );
21192
+ void logger.warn(
21193
+ "conversation_clear_plan_prepared",
21194
+ conversationMutationAuditFields(ctx, auth, {
21195
+ plan_id: plan.id,
21196
+ target_status: plan.target_status,
21197
+ total_count: plan.total_count
21198
+ })
21199
+ );
20194
21200
  ctx.status = 201;
20195
21201
  ctx.body = {
20196
21202
  ok: true,
@@ -20208,10 +21214,19 @@ function registerConversationRoutes(router, options) {
20208
21214
  router.post(
20209
21215
  "/api/v1/conversations/clear-plans/:planId/execute",
20210
21216
  async (ctx) => {
20211
- await authenticateRequest(ctx, paths);
21217
+ const auth = await authenticateRequest(ctx, paths);
20212
21218
  const plan = await conversations.startClearAllConversationPlan(
20213
21219
  ctx.params.planId
20214
21220
  );
21221
+ void logger.warn(
21222
+ "conversation_clear_plan_execute_requested",
21223
+ conversationMutationAuditFields(ctx, auth, {
21224
+ plan_id: plan.id,
21225
+ target_status: plan.target_status,
21226
+ total_count: plan.total_count,
21227
+ status: plan.status
21228
+ })
21229
+ );
20215
21230
  ctx.status = plan.status === "completed" ? 200 : 202;
20216
21231
  ctx.body = {
20217
21232
  ok: true,
@@ -20260,7 +21275,7 @@ function registerConversationRoutes(router, options) {
20260
21275
  }
20261
21276
  );
20262
21277
  router.delete("/api/v1/conversations", async (ctx) => {
20263
- await authenticateRequest(ctx, paths);
21278
+ const auth = await authenticateRequest(ctx, paths);
20264
21279
  const body = await readJsonBody(ctx.req);
20265
21280
  const conversationIds = readStringArray(
20266
21281
  body,
@@ -20275,6 +21290,14 @@ function registerConversationRoutes(router, options) {
20275
21290
  );
20276
21291
  }
20277
21292
  const deleted = await conversations.deleteConversations(conversationIds);
21293
+ void logger.warn(
21294
+ "conversation_bulk_delete_requested",
21295
+ conversationMutationAuditFields(ctx, auth, {
21296
+ requested_count: conversationIds.length,
21297
+ deleted_count: deleted.deleted_count,
21298
+ failed_count: deleted.failed_count
21299
+ })
21300
+ );
20278
21301
  const ok = deleted.failed_count === 0;
20279
21302
  ctx.status = ok ? 200 : 409;
20280
21303
  ctx.body = {
@@ -20302,6 +21325,33 @@ function registerConversationRoutes(router, options) {
20302
21325
  };
20303
21326
  }
20304
21327
  );
21328
+ router.post(
21329
+ "/api/v1/conversations/:conversationId/queued-runs/:runId/guide",
21330
+ async (ctx) => {
21331
+ await authenticateRequest(ctx, paths);
21332
+ ctx.status = 202;
21333
+ ctx.body = {
21334
+ ok: true,
21335
+ ...await conversations.guideQueuedRun(
21336
+ ctx.params.conversationId,
21337
+ ctx.params.runId
21338
+ )
21339
+ };
21340
+ }
21341
+ );
21342
+ router.post(
21343
+ "/api/v1/conversations/:conversationId/queued-runs/:runId/cancel",
21344
+ async (ctx) => {
21345
+ await authenticateRequest(ctx, paths);
21346
+ ctx.body = {
21347
+ ok: true,
21348
+ ...await conversations.cancelQueuedRun(
21349
+ ctx.params.conversationId,
21350
+ ctx.params.runId
21351
+ )
21352
+ };
21353
+ }
21354
+ );
20305
21355
  router.post(
20306
21356
  "/api/v1/conversations/:conversationId/approvals/:approvalId/approve",
20307
21357
  async (ctx) => {
@@ -20354,10 +21404,21 @@ function registerConversationRoutes(router, options) {
20354
21404
  }
20355
21405
  );
20356
21406
  router.delete("/api/v1/conversations/:conversationId", async (ctx) => {
20357
- await authenticateRequest(ctx, paths);
21407
+ const auth = await authenticateRequest(ctx, paths);
21408
+ const result = await conversations.deleteConversation(
21409
+ ctx.params.conversationId
21410
+ );
21411
+ void logger.warn(
21412
+ "conversation_delete_requested",
21413
+ conversationMutationAuditFields(ctx, auth, {
21414
+ conversation_id: result.conversation_id,
21415
+ hermes_deleted: result.hermes_deleted,
21416
+ hermes_session_count: result.hermes_session_ids?.length ?? 0
21417
+ })
21418
+ );
20358
21419
  ctx.body = {
20359
21420
  ok: true,
20360
- ...await conversations.deleteConversation(ctx.params.conversationId),
21421
+ ...result,
20361
21422
  blob_gc_completed: true
20362
21423
  };
20363
21424
  });
@@ -20404,6 +21465,22 @@ function registerConversationRoutes(router, options) {
20404
21465
  }
20405
21466
  );
20406
21467
  }
21468
+ async function prepareConversationListRead(conversations, logger, auth) {
21469
+ await conversations.backfillCronOwnership({
21470
+ accountId: auth.accountId,
21471
+ appInstanceId: auth.appInstanceId
21472
+ }).catch((error) => {
21473
+ void logger.warn("cron_owner_backfill_failed", {
21474
+ error: error instanceof Error ? error.message : String(error)
21475
+ });
21476
+ });
21477
+ await conversations.syncCronDeliveries().catch((error) => {
21478
+ void logger.warn("cron_link_delivery_sync_failed", {
21479
+ source: "conversation_list_read",
21480
+ error: error instanceof Error ? error.message : String(error)
21481
+ });
21482
+ });
21483
+ }
20407
21484
  function resolveConversationEventCursor(input) {
20408
21485
  const queryAfter = readInteger3(input.queryAfter) ?? 0;
20409
21486
  const headerAfter = readNonNegativeIntegerHeader(input.lastEventIdHeader) ?? 0;
@@ -20420,6 +21497,21 @@ function readConversationClearPlanTargetStatus(body) {
20420
21497
  "Conversation clear plan target status is invalid"
20421
21498
  );
20422
21499
  }
21500
+ function conversationMutationAuditFields(ctx, auth, fields) {
21501
+ return {
21502
+ method: ctx.method,
21503
+ path: ctx.path,
21504
+ auth_kind: auth.kind,
21505
+ device_id: auth.device?.id ?? null,
21506
+ device_label: auth.device?.label ?? null,
21507
+ device_platform: auth.device?.platform ?? null,
21508
+ device_model: auth.device?.model ?? null,
21509
+ account_id: auth.accountId ?? null,
21510
+ app_instance_id: auth.appInstanceId ?? null,
21511
+ user_agent: ctx.get("user-agent") || null,
21512
+ ...fields
21513
+ };
21514
+ }
20423
21515
  function readNonNegativeIntegerHeader(value) {
20424
21516
  const raw = Array.isArray(value) ? value[0] : value;
20425
21517
  if (!raw) {
@@ -20971,8 +22063,8 @@ function toRecord14(value) {
20971
22063
  function registerCronJobRoutes(router, options) {
20972
22064
  const { paths, logger, conversations, syncCronDeliveries } = options;
20973
22065
  router.get("/api/v1/cron-jobs", async (ctx) => {
20974
- await authenticateRequest(ctx, paths);
20975
- await syncCronDeliveries();
22066
+ const auth = await authenticateRequest(ctx, paths);
22067
+ await prepareCronJobRead(conversations, logger, auth, syncCronDeliveries);
20976
22068
  ctx.set("cache-control", "no-store");
20977
22069
  const includeDisabled = readQueryString(ctx.query.include_disabled)?.toLowerCase() === "true" || readQueryString(ctx.query.includeDisabled)?.toLowerCase() === "true";
20978
22070
  const profiles = await listHermesProfiles(paths);
@@ -21011,8 +22103,8 @@ function registerCronJobRoutes(router, options) {
21011
22103
  ctx.body = { ok: failures.length === 0, jobs, failures };
21012
22104
  });
21013
22105
  router.get("/api/v1/profiles/:name/cron-jobs", async (ctx) => {
21014
- await authenticateRequest(ctx, paths);
21015
- await syncCronDeliveries();
22106
+ const auth = await authenticateRequest(ctx, paths);
22107
+ await prepareCronJobRead(conversations, logger, auth, syncCronDeliveries);
21016
22108
  const profile = await getHermesProfileStatus(ctx.params.name, paths);
21017
22109
  ctx.set("cache-control", "no-store");
21018
22110
  const includeDisabled = readQueryString(ctx.query.include_disabled)?.toLowerCase() === "true" || readQueryString(ctx.query.includeDisabled)?.toLowerCase() === "true";
@@ -21031,7 +22123,7 @@ function registerCronJobRoutes(router, options) {
21031
22123
  };
21032
22124
  });
21033
22125
  router.post("/api/v1/profiles/:name/cron-jobs", async (ctx) => {
21034
- await authenticateRequest(ctx, paths);
22126
+ const auth = await authenticateRequest(ctx, paths);
21035
22127
  const profile = await getHermesProfileStatus(ctx.params.name, paths);
21036
22128
  const body = await readJsonBody(ctx.req);
21037
22129
  const input = readCronJobCreateInput(body);
@@ -21045,7 +22137,9 @@ function registerCronJobRoutes(router, options) {
21045
22137
  conversations,
21046
22138
  profileName: profile.name,
21047
22139
  job,
21048
- source: "app"
22140
+ source: "app",
22141
+ accountId: auth.accountId,
22142
+ appInstanceId: auth.appInstanceId
21049
22143
  }) : job;
21050
22144
  ctx.status = 201;
21051
22145
  ctx.body = { ok: true, job: attachCronJobProfile(decoratedJob, profile) };
@@ -21067,7 +22161,7 @@ function registerCronJobRoutes(router, options) {
21067
22161
  };
21068
22162
  });
21069
22163
  router.patch("/api/v1/profiles/:name/cron-jobs/:jobId", async (ctx) => {
21070
- await authenticateRequest(ctx, paths);
22164
+ const auth = await authenticateRequest(ctx, paths);
21071
22165
  const profile = await getHermesProfileStatus(ctx.params.name, paths);
21072
22166
  const body = await readJsonBody(ctx.req);
21073
22167
  const input = readCronJobUpdateInput(body);
@@ -21088,7 +22182,9 @@ function registerCronJobRoutes(router, options) {
21088
22182
  conversations,
21089
22183
  profileName: profile.name,
21090
22184
  job,
21091
- source: "app"
22185
+ source: "app",
22186
+ accountId: auth.accountId,
22187
+ appInstanceId: auth.appInstanceId
21092
22188
  });
21093
22189
  } else if (deliverTouched) {
21094
22190
  await unbindCronJobFromHermesLink(paths, profile.name, ctx.params.jobId);
@@ -21153,6 +22249,18 @@ function registerCronJobRoutes(router, options) {
21153
22249
  };
21154
22250
  });
21155
22251
  }
22252
+ async function prepareCronJobRead(conversations, logger, auth, syncCronDeliveries) {
22253
+ await conversations.backfillCronOwnership({
22254
+ accountId: auth.accountId,
22255
+ appInstanceId: auth.appInstanceId
22256
+ }).catch((error) => {
22257
+ void logger.warn("cron_owner_backfill_failed", {
22258
+ source: "cron_job_read",
22259
+ error: error instanceof Error ? error.message : String(error)
22260
+ });
22261
+ });
22262
+ await syncCronDeliveries();
22263
+ }
21156
22264
  function toHermesCronJobInput(input) {
21157
22265
  return {
21158
22266
  ...input,
@@ -21164,14 +22272,16 @@ async function bindAndDecorateCronJobForHermesLink(input) {
21164
22272
  if (!jobId) {
21165
22273
  return input.job;
21166
22274
  }
21167
- const conversationId = await input.conversations.ensureCronInboxConversation({
22275
+ const conversationId = input.source === "natural_language" ? await input.conversations.ensureCronInboxConversation({
21168
22276
  profileName: input.profileName
21169
- });
22277
+ }) : void 0;
21170
22278
  await bindCronJobToHermesLink(input.paths, {
21171
22279
  profileName: input.profileName,
21172
22280
  jobId,
21173
22281
  conversationId,
21174
- source: input.source
22282
+ source: input.source,
22283
+ ownerAccountId: input.accountId,
22284
+ ownerAppInstanceId: input.appInstanceId
21175
22285
  });
21176
22286
  return { ...input.job, deliver: HERMES_LINK_CRON_DELIVER };
21177
22287
  }
@@ -26263,12 +27373,10 @@ function computeRelayBackoffMs(attempt, options = {}) {
26263
27373
  return exponential + Math.floor(exponential * ratio);
26264
27374
  }
26265
27375
  async function updateRelayReconnectState(paths, update) {
26266
- const state = await readLinkState(paths);
26267
- const next = {
27376
+ await updateJsonFile(paths.stateFile, (state) => ({
26268
27377
  ...state,
26269
- relayReconnect: update(normalizeRelayReconnectState(state.relayReconnect))
26270
- };
26271
- await writeJsonFile(paths.stateFile, next);
27378
+ relayReconnect: update(normalizeRelayReconnectState(state?.relayReconnect))
27379
+ }));
26272
27380
  }
26273
27381
  async function readLinkState(paths) {
26274
27382
  const state = await readJsonFile(paths.stateFile);
@@ -26375,6 +27483,9 @@ function readInteger4(value) {
26375
27483
  }
26376
27484
 
26377
27485
  // src/relay/control-client.ts
27486
+ var DEFAULT_RELAY_HANDSHAKE_TIMEOUT_MS = 2e4;
27487
+ var DEFAULT_RELAY_PING_INTERVAL_MS = 3 * 6e4;
27488
+ var DEFAULT_RELAY_PONG_TIMEOUT_MS = 3e4;
26378
27489
  function connectRelayControl(options) {
26379
27490
  const wsUrl = new URL(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/link/connect`);
26380
27491
  wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
@@ -26383,10 +27494,15 @@ function connectRelayControl(options) {
26383
27494
  const maxReconnectAttempts = options.maxReconnectAttempts ?? Number.POSITIVE_INFINITY;
26384
27495
  const backoffBaseMs = options.backoffBaseMs ?? DEFAULT_RELAY_RECONNECT_BASE_MS;
26385
27496
  const backoffMaxMs = options.backoffMaxMs ?? DEFAULT_RELAY_RECONNECT_MAX_MS;
27497
+ const handshakeTimeoutMs = positiveInteger2(options.handshakeTimeoutMs, DEFAULT_RELAY_HANDSHAKE_TIMEOUT_MS);
27498
+ const pingIntervalMs = positiveInteger2(options.pingIntervalMs, DEFAULT_RELAY_PING_INTERVAL_MS);
27499
+ const pongTimeoutMs = positiveInteger2(options.pongTimeoutMs, DEFAULT_RELAY_PONG_TIMEOUT_MS);
26386
27500
  let reconnectAttempts = 0;
26387
27501
  let closedByUser = false;
26388
27502
  let socket = null;
26389
27503
  let retryTimer = null;
27504
+ let pingTimer = null;
27505
+ let pongTimer = null;
26390
27506
  let abortControllers = /* @__PURE__ */ new Map();
26391
27507
  let fatalRelayRejection = null;
26392
27508
  let relayRetryAfterMs = null;
@@ -26414,15 +27530,19 @@ function connectRelayControl(options) {
26414
27530
  });
26415
27531
  };
26416
27532
  const connect = () => {
27533
+ clearRetryTimer();
27534
+ clearHeartbeatTimers();
26417
27535
  options.onStatus?.({ state: "connecting", attempt: reconnectAttempts });
26418
27536
  fatalRelayRejection = null;
26419
27537
  relayRetryAfterMs = null;
26420
27538
  let closeHandled = false;
27539
+ let localCloseReason;
26421
27540
  const handleConnectionClosed = (reason) => {
26422
27541
  if (closeHandled) {
26423
27542
  return;
26424
27543
  }
26425
27544
  closeHandled = true;
27545
+ clearHeartbeatTimers();
26426
27546
  abortAll(abortControllers);
26427
27547
  abortControllers = /* @__PURE__ */ new Map();
26428
27548
  if (fatalRelayRejection) {
@@ -26449,30 +27569,48 @@ function connectRelayControl(options) {
26449
27569
  scheduleTimer(backoffMaxMs, "retrying", `Relay reconnect scheduling failed: ${message}`);
26450
27570
  });
26451
27571
  };
26452
- socket = new WebSocket(wsUrl, {
27572
+ const currentSocket = new WebSocket(wsUrl, {
27573
+ handshakeTimeout: handshakeTimeoutMs,
26453
27574
  headers: {
26454
27575
  "x-hermes-link-version": LINK_VERSION
26455
27576
  }
26456
27577
  });
26457
- socket.on("open", () => {
27578
+ socket = currentSocket;
27579
+ currentSocket.on("open", () => {
27580
+ if (socket !== currentSocket) {
27581
+ return;
27582
+ }
26458
27583
  reconnectAttempts = 0;
26459
27584
  void clearRelayReconnectState(paths).catch(() => void 0);
26460
27585
  options.onStatus?.({ state: "connected", attempt: reconnectAttempts });
26461
- const currentSocket = socket;
26462
- if (currentSocket && latestNetworkRoutes) {
27586
+ startHeartbeat(currentSocket, (message) => {
27587
+ localCloseReason = message;
27588
+ options.onStatus?.({ state: "disconnected", attempt: reconnectAttempts, message });
27589
+ currentSocket.terminate();
27590
+ });
27591
+ if (latestNetworkRoutes) {
26463
27592
  sendNetworkRoutes(currentSocket, options.linkId, latestNetworkRoutes);
26464
27593
  }
26465
27594
  });
26466
- socket.on("message", (raw) => {
26467
- if (!socket || typeof raw !== "string" && !Buffer.isBuffer(raw)) {
27595
+ currentSocket.on("pong", () => {
27596
+ if (socket !== currentSocket) {
27597
+ return;
27598
+ }
27599
+ clearPongTimer();
27600
+ });
27601
+ currentSocket.on("message", (raw) => {
27602
+ if (socket !== currentSocket || typeof raw !== "string" && !Buffer.isBuffer(raw)) {
26468
27603
  return;
26469
27604
  }
26470
- void handleFrame(socket, String(raw), options.localPort, abortControllers, streamBatchPolicy).catch((error) => {
27605
+ void handleFrame(currentSocket, String(raw), options.localPort, abortControllers, streamBatchPolicy).catch((error) => {
26471
27606
  const message = error instanceof Error ? error.message : "Relay request failed";
26472
- socket?.send(JSON.stringify({ type: "http.error", id: "unknown", status: 502, message }));
27607
+ currentSocket.send(JSON.stringify({ type: "http.error", id: "unknown", status: 502, message }));
26473
27608
  });
26474
27609
  });
26475
- socket.on("unexpected-response", (request, response) => {
27610
+ currentSocket.on("unexpected-response", (request, response) => {
27611
+ if (socket !== currentSocket) {
27612
+ return;
27613
+ }
26476
27614
  const statusCode = response.statusCode ?? 0;
26477
27615
  fatalRelayRejection = resolveFatalRelayRejectionFromStatus(statusCode);
26478
27616
  relayRetryAfterMs = readRetryAfterMs(response);
@@ -26486,7 +27624,10 @@ function connectRelayControl(options) {
26486
27624
  handleConnectionClosed(message);
26487
27625
  request.destroy();
26488
27626
  });
26489
- socket.on("error", (error) => {
27627
+ currentSocket.on("error", (error) => {
27628
+ if (socket !== currentSocket) {
27629
+ return;
27630
+ }
26490
27631
  const message = error instanceof Error ? error.message : "Relay websocket error";
26491
27632
  fatalRelayRejection = resolveFatalRelayRejection(message);
26492
27633
  options.onStatus?.({
@@ -26495,8 +27636,12 @@ function connectRelayControl(options) {
26495
27636
  message: fatalRelayRejection ?? message
26496
27637
  });
26497
27638
  });
26498
- socket.on("close", () => {
26499
- handleConnectionClosed();
27639
+ currentSocket.on("close", (code, reason) => {
27640
+ if (socket !== currentSocket) {
27641
+ return;
27642
+ }
27643
+ socket = null;
27644
+ handleConnectionClosed(localCloseReason ?? formatCloseReason(code, reason));
26500
27645
  });
26501
27646
  };
26502
27647
  startConnect();
@@ -26526,10 +27671,59 @@ function connectRelayControl(options) {
26526
27671
  return await readRelayCooldownDelayMs(paths).catch(() => 0);
26527
27672
  }
26528
27673
  function scheduleTimer(delay4, state, message) {
27674
+ clearRetryTimer();
26529
27675
  options.onStatus?.({ state, attempt: reconnectAttempts, message });
26530
27676
  retryTimer = setTimeout(connect, delay4);
26531
27677
  retryTimer.unref?.();
26532
27678
  }
27679
+ function clearRetryTimer() {
27680
+ if (retryTimer == null) {
27681
+ return;
27682
+ }
27683
+ clearTimeout(retryTimer);
27684
+ retryTimer = null;
27685
+ }
27686
+ function startHeartbeat(currentSocket, onTimeout) {
27687
+ clearHeartbeatTimers();
27688
+ pingTimer = setInterval(() => {
27689
+ if (socket !== currentSocket || currentSocket.readyState !== WebSocket.OPEN) {
27690
+ clearHeartbeatTimers();
27691
+ return;
27692
+ }
27693
+ if (pongTimer != null) {
27694
+ return;
27695
+ }
27696
+ try {
27697
+ currentSocket.ping();
27698
+ } catch {
27699
+ onTimeout("Relay websocket ping failed");
27700
+ return;
27701
+ }
27702
+ pongTimer = setTimeout(() => {
27703
+ if (socket !== currentSocket || currentSocket.readyState !== WebSocket.OPEN) {
27704
+ clearPongTimer();
27705
+ return;
27706
+ }
27707
+ onTimeout(`Relay websocket pong timed out after ${pongTimeoutMs}ms`);
27708
+ }, pongTimeoutMs);
27709
+ pongTimer.unref?.();
27710
+ }, pingIntervalMs);
27711
+ pingTimer.unref?.();
27712
+ }
27713
+ function clearHeartbeatTimers() {
27714
+ if (pingTimer != null) {
27715
+ clearInterval(pingTimer);
27716
+ pingTimer = null;
27717
+ }
27718
+ clearPongTimer();
27719
+ }
27720
+ function clearPongTimer() {
27721
+ if (pongTimer == null) {
27722
+ return;
27723
+ }
27724
+ clearTimeout(pongTimer);
27725
+ pongTimer = null;
27726
+ }
26533
27727
  return {
26534
27728
  publishNetworkRoutes(routes) {
26535
27729
  latestNetworkRoutes = routes;
@@ -26543,10 +27737,8 @@ function connectRelayControl(options) {
26543
27737
  },
26544
27738
  close() {
26545
27739
  closedByUser = true;
26546
- if (retryTimer) {
26547
- clearTimeout(retryTimer);
26548
- retryTimer = null;
26549
- }
27740
+ clearRetryTimer();
27741
+ clearHeartbeatTimers();
26550
27742
  abortAll(abortControllers);
26551
27743
  socket?.terminate();
26552
27744
  }
@@ -26598,6 +27790,16 @@ function readRetryAfterMs(response) {
26598
27790
  }
26599
27791
  return Math.max(0, dateMs - Date.now());
26600
27792
  }
27793
+ function formatCloseReason(code, reason) {
27794
+ const text = reason.toString("utf8").trim();
27795
+ if (code === 1e3 && !text) {
27796
+ return void 0;
27797
+ }
27798
+ return text ? `Relay websocket closed (${code}): ${text}` : `Relay websocket closed (${code})`;
27799
+ }
27800
+ function positiveInteger2(value, fallback) {
27801
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : fallback;
27802
+ }
26601
27803
  async function handleFrame(socket, raw, localPort, abortControllers, streamBatchPolicy) {
26602
27804
  const frame = JSON.parse(raw);
26603
27805
  if (frame.type === "relay.config.update") {
@@ -26723,8 +27925,7 @@ async function readRelayStatusSnapshot(paths) {
26723
27925
  return normalizeRelayStatusSnapshot(state.relayStatus);
26724
27926
  }
26725
27927
  async function writeRelayStatusSnapshot(paths, status, now = /* @__PURE__ */ new Date()) {
26726
- const current = await readLinkState2(paths);
26727
- await writeJsonFile(paths.stateFile, {
27928
+ await updateLinkState(paths, (current) => ({
26728
27929
  ...current,
26729
27930
  relayStatus: {
26730
27931
  state: status.state,
@@ -26732,7 +27933,10 @@ async function writeRelayStatusSnapshot(paths, status, now = /* @__PURE__ */ new
26732
27933
  message: normalizeMessage(status.message),
26733
27934
  updatedAt: now.toISOString()
26734
27935
  }
26735
- });
27936
+ }));
27937
+ }
27938
+ async function updateLinkState(paths, update) {
27939
+ await updateJsonFile(paths.stateFile, (state) => update(state && typeof state === "object" ? state : {}));
26736
27940
  }
26737
27941
  async function readLinkState2(paths) {
26738
27942
  const state = await readJsonFile(paths.stateFile);
@@ -27151,12 +28355,10 @@ async function mergeLastReportedPublicRoutes(paths, snapshotInput) {
27151
28355
  };
27152
28356
  }
27153
28357
  async function updateNetworkReportState(paths, update) {
27154
- const state = await readLinkState3(paths);
27155
- const next = {
28358
+ await updateJsonFile(paths.stateFile, (state) => ({
27156
28359
  ...state,
27157
- networkReport: update(normalizeNetworkReportState(state.networkReport))
27158
- };
27159
- await writeJsonFile(paths.stateFile, next);
28360
+ networkReport: update(normalizeNetworkReportState(state?.networkReport))
28361
+ }));
27160
28362
  }
27161
28363
  async function readLinkState3(paths) {
27162
28364
  const state = await readJsonFile(paths.stateFile);
@@ -27277,7 +28479,7 @@ async function reportLinkStatusToServer(options = {}) {
27277
28479
  public_ipv6s: routes.publicIpv6s,
27278
28480
  reported_at: (/* @__PURE__ */ new Date()).toISOString()
27279
28481
  };
27280
- const signature = signIdentityPayload(identity, canonicalJson(payload));
28482
+ const signature = signIdentityPayload(identity, canonicalJson2(payload));
27281
28483
  const fetcher = options.fetchImpl ?? fetch;
27282
28484
  const response = await fetcher(
27283
28485
  `${config.serverBaseUrl.replace(/\/+$/u, "")}/api/v1/links/${encodeURIComponent(identity.link_id)}/report`,
@@ -27296,30 +28498,30 @@ async function reportLinkStatusToServer(options = {}) {
27296
28498
  );
27297
28499
  const body = await response.json().catch(() => null);
27298
28500
  if (!response.ok || !body) {
27299
- const message = readErrorMessage3(body) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
28501
+ const message = readErrorMessage4(body) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
27300
28502
  throw new LinkHttpError(response.status, "server_request_failed", message);
27301
28503
  }
27302
28504
  await markNetworkStatusReported(paths, routes);
27303
28505
  return body;
27304
28506
  }
27305
- function canonicalJson(value) {
27306
- return JSON.stringify(sortJsonValue(value));
28507
+ function canonicalJson2(value) {
28508
+ return JSON.stringify(sortJsonValue2(value));
27307
28509
  }
27308
- function sortJsonValue(value) {
28510
+ function sortJsonValue2(value) {
27309
28511
  if (Array.isArray(value)) {
27310
- return value.map(sortJsonValue);
28512
+ return value.map(sortJsonValue2);
27311
28513
  }
27312
28514
  if (value && typeof value === "object") {
27313
28515
  const record = value;
27314
28516
  const sorted = {};
27315
28517
  for (const key of Object.keys(record).sort()) {
27316
- sorted[key] = sortJsonValue(record[key]);
28518
+ sorted[key] = sortJsonValue2(record[key]);
27317
28519
  }
27318
28520
  return sorted;
27319
28521
  }
27320
28522
  return value;
27321
28523
  }
27322
- function readErrorMessage3(payload) {
28524
+ function readErrorMessage4(payload) {
27323
28525
  if (typeof payload !== "object" || payload === null) {
27324
28526
  return null;
27325
28527
  }
@@ -27515,20 +28717,65 @@ function wait(ms) {
27515
28717
  }
27516
28718
 
27517
28719
  // src/daemon/scheduler.ts
28720
+ import { watch } from "fs";
27518
28721
  function startCronDeliveryScheduler(options) {
27519
28722
  let running = false;
27520
28723
  let current = Promise.resolve();
28724
+ let debounceTimer = null;
28725
+ const watchers = /* @__PURE__ */ new Map();
28726
+ const refreshWatchers = async () => {
28727
+ const dirs = await listHermesLinkCronOutputWatchDirs(options.paths).catch(
28728
+ (error) => {
28729
+ void options.logger.warn("cron_link_delivery_watch_failed", {
28730
+ error: error instanceof Error ? error.message : String(error)
28731
+ });
28732
+ return [];
28733
+ }
28734
+ );
28735
+ const nextDirs = new Set(dirs);
28736
+ for (const [dir, watcher] of watchers) {
28737
+ if (!nextDirs.has(dir)) {
28738
+ watcher.close();
28739
+ watchers.delete(dir);
28740
+ }
28741
+ }
28742
+ for (const dir of nextDirs) {
28743
+ if (watchers.has(dir)) {
28744
+ continue;
28745
+ }
28746
+ try {
28747
+ const watcher = watch(
28748
+ dir,
28749
+ { persistent: false, recursive: true },
28750
+ () => {
28751
+ triggerSync();
28752
+ }
28753
+ );
28754
+ watcher.on("error", (error) => {
28755
+ void options.logger.warn("cron_link_delivery_watch_failed", {
28756
+ dir,
28757
+ error: error instanceof Error ? error.message : String(error)
28758
+ });
28759
+ watcher.close();
28760
+ watchers.delete(dir);
28761
+ });
28762
+ watchers.set(dir, watcher);
28763
+ } catch (error) {
28764
+ void options.logger.warn("cron_link_delivery_watch_failed", {
28765
+ dir,
28766
+ error: error instanceof Error ? error.message : String(error)
28767
+ });
28768
+ }
28769
+ }
28770
+ };
27521
28771
  const syncCronDeliveries = async () => {
27522
28772
  if (running) {
27523
28773
  return;
27524
28774
  }
27525
28775
  running = true;
27526
28776
  try {
27527
- await syncHermesLinkCronDeliveries(
27528
- options.paths,
27529
- options.conversations,
27530
- options.logger
27531
- );
28777
+ await options.conversations.syncCronDeliveries();
28778
+ await refreshWatchers();
27532
28779
  } catch (error) {
27533
28780
  void options.logger.warn("cron_link_delivery_sync_failed", {
27534
28781
  source: "daemon_scheduler",
@@ -27538,13 +28785,32 @@ function startCronDeliveryScheduler(options) {
27538
28785
  running = false;
27539
28786
  }
27540
28787
  };
28788
+ const triggerSync = () => {
28789
+ if (debounceTimer) {
28790
+ clearTimeout(debounceTimer);
28791
+ }
28792
+ debounceTimer = setTimeout(() => {
28793
+ debounceTimer = null;
28794
+ current = syncCronDeliveries();
28795
+ }, 500);
28796
+ debounceTimer.unref?.();
28797
+ };
27541
28798
  const timer = setInterval(() => {
27542
28799
  current = syncCronDeliveries();
27543
28800
  }, options.intervalMs ?? 3e4);
27544
28801
  timer.unref?.();
28802
+ void refreshWatchers();
28803
+ triggerSync();
27545
28804
  return {
27546
28805
  async close() {
27547
28806
  clearInterval(timer);
28807
+ if (debounceTimer) {
28808
+ clearTimeout(debounceTimer);
28809
+ }
28810
+ for (const watcher of watchers.values()) {
28811
+ watcher.close();
28812
+ }
28813
+ watchers.clear();
27548
28814
  await current.catch(() => void 0);
27549
28815
  }
27550
28816
  };
@@ -29258,7 +30524,7 @@ async function postJson(fetcher, url, token, body) {
29258
30524
  }
29259
30525
  const payload = await response.json().catch(() => null);
29260
30526
  if (!response.ok) {
29261
- const message = readErrorMessage4(payload) ?? `Relay request failed with HTTP ${response.status}`;
30527
+ const message = readErrorMessage5(payload) ?? `Relay request failed with HTTP ${response.status}`;
29262
30528
  throw new Error(message);
29263
30529
  }
29264
30530
  if (!payload) {
@@ -29266,7 +30532,7 @@ async function postJson(fetcher, url, token, body) {
29266
30532
  }
29267
30533
  return payload;
29268
30534
  }
29269
- function readErrorMessage4(payload) {
30535
+ function readErrorMessage5(payload) {
29270
30536
  if (typeof payload !== "object" || payload === null) {
29271
30537
  return null;
29272
30538
  }
@@ -29600,12 +30866,12 @@ async function patchServerJson(serverBaseUrl, path29, token, body, options) {
29600
30866
  async function readJsonResponse2(response) {
29601
30867
  const payload = await response.json().catch(() => null);
29602
30868
  if (!response.ok || !payload) {
29603
- const message = readErrorMessage5(payload) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
30869
+ const message = readErrorMessage6(payload) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
29604
30870
  throw new LinkHttpError(response.status, "server_request_failed", message);
29605
30871
  }
29606
30872
  return payload;
29607
30873
  }
29608
- function readErrorMessage5(payload) {
30874
+ function readErrorMessage6(payload) {
29609
30875
  if (typeof payload !== "object" || payload === null) {
29610
30876
  return null;
29611
30877
  }
@@ -29689,6 +30955,9 @@ function registerSystemRoutes(router, options) {
29689
30955
  conversation_bulk_delete: true,
29690
30956
  conversation_clear_plan: true,
29691
30957
  conversation_cancel: true,
30958
+ conversation_queue_controls: true,
30959
+ conversation_queue_limit: MAX_CONVERSATION_QUEUED_RUNS,
30960
+ responses_interrupted_previous_response: true,
29692
30961
  conversation_rename: true,
29693
30962
  blobs: true,
29694
30963
  devices: true,
@@ -29699,7 +30968,8 @@ function registerSystemRoutes(router, options) {
29699
30968
  cron_jobs: true,
29700
30969
  profile_skills: true,
29701
30970
  profile_memory: true,
29702
- hermes_updates: true
30971
+ hermes_updates: true,
30972
+ app_push_notification_events: true
29703
30973
  }
29704
30974
  };
29705
30975
  });
@@ -30774,7 +32044,7 @@ async function createApp(options = {}) {
30774
32044
  }
30775
32045
  cronDeliverySyncRunning = true;
30776
32046
  try {
30777
- await syncHermesLinkCronDeliveries(paths, conversations, logger);
32047
+ await conversations.syncCronDeliveries();
30778
32048
  } catch (error) {
30779
32049
  void logger.warn("cron_link_delivery_sync_failed", {
30780
32050
  source: "http_app_bootstrap",
@@ -30817,7 +32087,7 @@ async function createApp(options = {}) {
30817
32087
  conversations,
30818
32088
  syncCronDeliveries
30819
32089
  });
30820
- registerConversationRoutes(router, { paths, conversations });
32090
+ registerConversationRoutes(router, { paths, logger, conversations });
30821
32091
  registerRunRoutes(router, { paths, logger, conversations });
30822
32092
  registerProfileRoutes(router, { paths, logger, conversations });
30823
32093
  app.use(router.routes());
@@ -30838,6 +32108,14 @@ export {
30838
32108
  resolveHermesConfigPath,
30839
32109
  ensureHermesApiServerConfig,
30840
32110
  resolveRuntimePaths,
32111
+ defaultLinkConfig,
32112
+ loadConfig,
32113
+ saveConfig,
32114
+ parseLogLevel,
32115
+ normalizeLanHost,
32116
+ loadIdentity,
32117
+ ensureIdentity,
32118
+ getIdentityStatus,
30841
32119
  createFileLogger,
30842
32120
  getLinkLogFile,
30843
32121
  readRecentLogEntries,
@@ -30849,14 +32127,6 @@ export {
30849
32127
  ensureHermesApiServerAvailable,
30850
32128
  readHermesVersion,
30851
32129
  readHermesApiServerHealth,
30852
- defaultLinkConfig,
30853
- loadConfig,
30854
- saveConfig,
30855
- parseLogLevel,
30856
- normalizeLanHost,
30857
- loadIdentity,
30858
- ensureIdentity,
30859
- getIdentityStatus,
30860
32130
  ConversationService,
30861
32131
  hasActiveDevices,
30862
32132
  prepareHermesProfilesForUse,