@songsid/agend 2.2.0-beta.6 → 2.2.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/agent-cli-instructions.md +6 -2
  2. package/dist/agent-cli.js +9 -5
  3. package/dist/agent-cli.js.map +1 -1
  4. package/dist/backend/muse.d.ts +26 -0
  5. package/dist/backend/muse.js +131 -0
  6. package/dist/backend/muse.js.map +1 -1
  7. package/dist/channel/adapters/discord.js +12 -1
  8. package/dist/channel/adapters/discord.js.map +1 -1
  9. package/dist/channel/adapters/telegram.js +6 -0
  10. package/dist/channel/adapters/telegram.js.map +1 -1
  11. package/dist/channel/mcp-tools.js +3 -3
  12. package/dist/channel/mcp-tools.js.map +1 -1
  13. package/dist/channel/types.d.ts +3 -0
  14. package/dist/cross-instance-envelope.js +2 -0
  15. package/dist/cross-instance-envelope.js.map +1 -1
  16. package/dist/daemon.d.ts +6 -0
  17. package/dist/daemon.js +124 -54
  18. package/dist/daemon.js.map +1 -1
  19. package/dist/delivery-outbox.d.ts +27 -0
  20. package/dist/delivery-outbox.js +175 -6
  21. package/dist/delivery-outbox.js.map +1 -1
  22. package/dist/fleet-manager.d.ts +28 -1
  23. package/dist/fleet-manager.js +203 -40
  24. package/dist/fleet-manager.js.map +1 -1
  25. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +14 -1
  26. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +4 -0
  27. package/dist/human-delivery-receipts.d.ts +83 -0
  28. package/dist/human-delivery-receipts.js +194 -0
  29. package/dist/human-delivery-receipts.js.map +1 -0
  30. package/dist/instructions.d.ts +1 -1
  31. package/dist/instructions.js +1 -1
  32. package/dist/instructions.js.map +1 -1
  33. package/dist/locale.js +8 -0
  34. package/dist/locale.js.map +1 -1
  35. package/dist/outbound-handlers.d.ts +3 -0
  36. package/dist/outbound-handlers.js +20 -4
  37. package/dist/outbound-handlers.js.map +1 -1
  38. package/dist/outbound-schemas.d.ts +15 -13
  39. package/dist/outbound-schemas.js +5 -0
  40. package/dist/outbound-schemas.js.map +1 -1
  41. package/dist/public-link-progress.d.ts +1 -1
  42. package/dist/public-link-progress.js +8 -2
  43. package/dist/public-link-progress.js.map +1 -1
  44. package/dist/public-preview.d.ts +2 -1
  45. package/dist/public-preview.js +8 -5
  46. package/dist/public-preview.js.map +1 -1
  47. package/dist/public-web-link.d.ts +4 -1
  48. package/dist/public-web-link.js +27 -10
  49. package/dist/public-web-link.js.map +1 -1
  50. package/dist/reply-buttons-controller.d.ts +3 -0
  51. package/dist/reply-buttons-controller.js +3 -2
  52. package/dist/reply-buttons-controller.js.map +1 -1
  53. package/dist/topic-commands.d.ts +3 -2
  54. package/dist/topic-commands.js +14 -4
  55. package/dist/topic-commands.js.map +1 -1
  56. package/dist/tunnel/cloudflared.d.ts +7 -15
  57. package/dist/tunnel/cloudflared.js +36 -1
  58. package/dist/tunnel/cloudflared.js.map +1 -1
  59. package/dist/tunnel/types.d.ts +5 -1
  60. package/dist/tunnel/types.js.map +1 -1
  61. package/dist/ui/chat-strings.js +8 -2
  62. package/dist/ui/chat-thread.js +24 -4
  63. package/dist/ui/chat-time.js +80 -0
  64. package/dist/ui/preview-panel.js +7 -1
  65. package/dist/ui/preview.js +5 -1
  66. package/dist/ui/shared/app.css +4 -1
  67. package/dist/web-api.d.ts +2 -0
  68. package/dist/web-api.js +8 -1
  69. package/dist/web-api.js.map +1 -1
  70. package/dist/web-commands.d.ts +2 -2
  71. package/dist/web-commands.js +3 -2
  72. package/dist/web-commands.js.map +1 -1
  73. package/dist/web-reply-files.d.ts +45 -0
  74. package/dist/web-reply-files.js +223 -0
  75. package/dist/web-reply-files.js.map +1 -0
  76. package/dist/web-upload.d.ts +17 -4
  77. package/dist/web-upload.js +64 -13
  78. package/dist/web-upload.js.map +1 -1
  79. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { type HumanOrigin } from "./human-delivery-receipts.js";
1
2
  import { SettingsExecution } from "./settings-transaction.js";
2
3
  import type { LoginCodeOwner } from "./web-login.js";
3
4
  import { type ProfileTicket } from "./runtime-cpu-profile.js";
@@ -63,6 +64,7 @@ import { type AgentEndpointContext } from "./agent-endpoint.js";
63
64
  import { ClassicChannelManager } from "./classic-channel-manager.js";
64
65
  import { type ExplicitInstanceRemoval } from "./instance-removal.js";
65
66
  import type { InstanceState, InteractionSnapshot } from "./backend/types.js";
67
+ import { type Daemon } from "./daemon.js";
66
68
  import { WakeCoordinator } from "./wake-coordinator.js";
67
69
  import { TargetQueueWorker } from "./target-queue-worker.js";
68
70
  import { resolveDeliveryWorkerMode } from "./types.js";
@@ -218,7 +220,7 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
218
220
  private replyObligationTimer;
219
221
  private readonly activeDurableTargets;
220
222
  /** Live view of lifecycle.daemons — used throughout; not deprecated. */
221
- get daemons(): Map<string, import("./daemon.js").Daemon>;
223
+ get daemons(): Map<string, Daemon>;
222
224
  fleetConfig: FleetConfig | null;
223
225
  private rawFleetConfig;
224
226
  private rawFleetDocument;
@@ -470,6 +472,10 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
470
472
  private webChatStore;
471
473
  /** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
472
474
  readonly webFiles: WebFileLedger;
475
+ /** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
476
+ private readonly webReplyOrder;
477
+ /** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
478
+ private webReplyMaxBytes;
473
479
  /** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
474
480
  readonly seenUsers: SeenUsers;
475
481
  private webPrefsStore?;
@@ -555,10 +561,13 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
555
561
  kind: string;
556
562
  correlationId: string;
557
563
  payload: Record<string, unknown>;
564
+ forwards?: readonly string[];
565
+ requestedKind?: string;
558
566
  }): {
559
567
  deliveryId: string;
560
568
  state: string;
561
569
  duplicate: boolean;
570
+ kind: string;
562
571
  };
563
572
  queryDurableDeliveryStatus(callerInstance: string, selector: DeliveryStatusSelector): DeliveryStatusPage;
564
573
  /** #1641: why a target is not taking queued work right now, or null when nothing known holds it. */
@@ -902,6 +911,17 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
902
911
  hasPendingIdleGatedDelivery(instanceName: string): boolean;
903
912
  /** The running launch's own steer capability (#1405); undefined for a backend without one, or no running Daemon. */
904
913
  instanceLaunchSupportsSteer(instanceName: string): boolean | undefined;
914
+ private humanReceiptDaemons;
915
+ /** Cached FM + lifecycle + Daemon + tmux owner; unknown ownership grants nothing. */
916
+ private humanReceiptOwner;
917
+ private listenForHumanReceipt;
918
+ /** Evidence-only admission. Bots/system/agent payloads have no trusted origin. */
919
+ private humanReceiptPayload;
920
+ /** Bind at the actual IPC send, after all wake/reconnect waits. No human retry lane. */
921
+ private bindHumanReceiptPayload;
922
+ deliverHumanToInstance(instanceName: string, payload: Record<string, unknown>, origin: HumanOrigin | undefined, originalBody: string, options?: DeliveryOptions): Promise<boolean | void>;
923
+ /** Synchronous steer/btw handoff, using the same evidence and owner binding. */
924
+ sendHumanControl(instanceName: string, payload: Record<string, unknown>, origin: HumanOrigin | undefined, originalBody: string): boolean;
905
925
  /** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
906
926
  deliverToInstance(instanceName: string, payload: Record<string, unknown>, options?: DeliveryOptions): Promise<boolean | void>;
907
927
  /**
@@ -1580,7 +1600,14 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1580
1600
  private isWebOnlyFleet;
1581
1601
  /** On a fleet with no chat platform, the dashboard is where an instance-health prompt is posted (#1307 item 6). */
1582
1602
  private webOnlyPromptPlace;
1603
+ /** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
1583
1604
  private afterReplyRouted;
1605
+ /**
1606
+ * #1770: a reply's files as the web chat shows them. Each is copied into the instance's web-files/ and served from
1607
+ * the copy. One the copy refuses (a symlink, not a regular file, over 50 MB, a state file, missing) is not shown;
1608
+ * one that could not be copied (I/O) is served from the agent's own path, as before — until the fleet restarts.
1609
+ */
1610
+ private webReplyAttachments;
1584
1611
  private handleScheduleTrigger;
1585
1612
  /**
1586
1613
  * The adapter that can actually post into a chat, found by its group.
@@ -1,3 +1,4 @@
1
+ import { humanOwnerObjectId, webHumanOrigin } from "./human-delivery-receipts.js";
1
2
  import { SettingsBaselines } from "./settings-baseline.js";
2
3
  import { SettingsConfirmationStore } from "./settings-confirmation.js";
3
4
  import { SettingsHttpConfirmation } from "./settings-http-confirmation.js";
@@ -138,6 +139,7 @@ import { WebPrefsStore } from "./web-prefs.js";
138
139
  import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
139
140
  import { ReplyButtonsController } from "./reply-buttons-controller.js";
140
141
  import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
142
+ import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
141
143
  import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
142
144
  import { downloadStickerImage } from "./channel/sticker-download.js";
143
145
  import { WebImageCache, imageMimeForFile, WEB_IMAGE_MAX_AGE_MS, WEB_IMAGE_MAX_BYTES, WEB_IMAGE_RETRY_MS } from "./web-images.js";
@@ -789,6 +791,10 @@ export class FleetManager {
789
791
  webChatStore = null;
790
792
  /** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
791
793
  webFiles = new WebFileLedger();
794
+ /** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
795
+ webReplyOrder = new Map();
796
+ /** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
797
+ webReplyMaxBytes = REPLY_FILES_MAX_BYTES;
792
798
  /** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
793
799
  seenUsers = new SeenUsers();
794
800
  webPrefsStore;
@@ -1189,7 +1195,7 @@ export class FleetManager {
1189
1195
  const reactions = this.pendingReactionsMeta(set.instance);
1190
1196
  let accepted = false;
1191
1197
  try {
1192
- accepted = (await this.deliverToInstance(set.instance, {
1198
+ accepted = (await this.deliverHumanToInstance(set.instance, {
1193
1199
  type: "fleet_inbound",
1194
1200
  content,
1195
1201
  targetSession: set.instance,
@@ -1198,7 +1204,7 @@ export class FleetManager {
1198
1204
  thread_id: set.threadId, adapter_id: inbound.adapterId, source: inbound.source,
1199
1205
  ...reactions.meta,
1200
1206
  },
1201
- })) !== false;
1207
+ }, by.humanOrigin, button.label)) !== false;
1202
1208
  }
1203
1209
  finally {
1204
1210
  // Both false and throw reopen the choice in the controller. Finish the
@@ -1317,6 +1323,7 @@ export class FleetManager {
1317
1323
  deliveryId: admitted.delivery.deliveryId,
1318
1324
  state: admitted.delivery.state,
1319
1325
  duplicate: !admitted.inserted,
1326
+ kind: admitted.delivery.kind,
1320
1327
  };
1321
1328
  }
1322
1329
  queryDurableDeliveryStatus(callerInstance, selector) {
@@ -2989,7 +2996,7 @@ export class FleetManager {
2989
2996
  const queued = this.ipcWaitTails.get(instanceName);
2990
2997
  if (!queued) {
2991
2998
  const ipc = this.instanceIpcClients.get(instanceName);
2992
- if (ipc?.connected && ipc.send(payload))
2999
+ if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
2993
3000
  return true;
2994
3001
  }
2995
3002
  const attempt = (queued ?? Promise.resolve())
@@ -3024,7 +3031,7 @@ export class FleetManager {
3024
3031
  // Re-read every round: a reconnect replaces the IpcClient object entirely,
3025
3032
  // so a cached reference would stay dead forever.
3026
3033
  const ipc = this.instanceIpcClients.get(instanceName);
3027
- if (ipc?.connected && ipc.send(payload))
3034
+ if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
3028
3035
  return true;
3029
3036
  if (Date.now() >= deadline) {
3030
3037
  throw new Error(`Instance '${instanceName}' IPC is unavailable`);
@@ -3242,6 +3249,101 @@ export class FleetManager {
3242
3249
  instanceLaunchSupportsSteer(instanceName) {
3243
3250
  return this.daemons.get(instanceName)?.launchSupportsSteer();
3244
3251
  }
3252
+ humanReceiptDaemons = new WeakSet();
3253
+ /** Cached FM + lifecycle + Daemon + tmux owner; unknown ownership grants nothing. */
3254
+ humanReceiptOwner(instanceName, daemon = this.daemons.get(instanceName)) {
3255
+ try {
3256
+ if (this.shuttingDown || !daemon || this.daemons.get(instanceName) !== daemon)
3257
+ return null;
3258
+ const epoch = this.lifecycle.epochOf(instanceName);
3259
+ const owner = daemon.captureHumanReceiptOwner();
3260
+ if (!Number.isSafeInteger(epoch) || !owner)
3261
+ return null;
3262
+ return JSON.stringify([this.managerBootId, epoch, this.getDeliveryEpoch(instanceName), humanOwnerObjectId(daemon), owner]);
3263
+ }
3264
+ catch {
3265
+ return null;
3266
+ }
3267
+ }
3268
+ listenForHumanReceipt(instanceName, daemon) {
3269
+ // Lazy initialization also supports narrow prototype-based integration fixtures.
3270
+ this.humanReceiptDaemons ??= new WeakSet();
3271
+ if (this.humanReceiptDaemons.has(daemon))
3272
+ return;
3273
+ this.humanReceiptDaemons.add(daemon);
3274
+ daemon.on("human_receipt_result", (result) => {
3275
+ try {
3276
+ this.deliveryOutbox?.settleHumanReceipt(result.receipt_id, instanceName, result.owner_key, result.proof, this.humanReceiptOwner(instanceName, daemon));
3277
+ }
3278
+ catch (err) {
3279
+ this.logger.error({ err, instanceName }, "Could not persist human delivery evidence");
3280
+ }
3281
+ });
3282
+ }
3283
+ /** Evidence-only admission. Bots/system/agent payloads have no trusted origin. */
3284
+ humanReceiptPayload(instanceName, payload, origin, originalBody) {
3285
+ if (!origin || !this.deliveryOutbox)
3286
+ return payload;
3287
+ const row = this.deliveryOutbox.createHumanReceipt({ targetInstance: instanceName, originalBody,
3288
+ origin: { ...origin }, inputKind: String(payload.type), managerBootId: this.managerBootId });
3289
+ return { ...payload, meta: { ...(payload.meta ?? {}),
3290
+ human_receipt_id: row.receipt_id, verification_id: row.message_id } };
3291
+ }
3292
+ /** Bind at the actual IPC send, after all wake/reconnect waits. No human retry lane. */
3293
+ bindHumanReceiptPayload(instanceName, payload) {
3294
+ const meta = payload.meta;
3295
+ if (!meta?.human_receipt_id || !this.deliveryOutbox)
3296
+ return payload;
3297
+ const daemon = this.daemons.get(instanceName);
3298
+ const owner = this.humanReceiptOwner(instanceName, daemon);
3299
+ if (!daemon || !owner) {
3300
+ this.deliveryOutbox.markHumanReceiptUncertain(meta.human_receipt_id, instanceName);
3301
+ return payload;
3302
+ }
3303
+ this.listenForHumanReceipt(instanceName, daemon);
3304
+ const daemonOwner = daemon.captureHumanReceiptOwner();
3305
+ const wire = daemon.humanReceiptWire(String(payload.content ?? ""), meta, String(payload.type));
3306
+ const row = this.deliveryOutbox.bindHumanReceipt(meta.human_receipt_id, instanceName, owner, wire, this.managerBootId);
3307
+ if (!daemonOwner || this.humanReceiptOwner(instanceName, daemon) !== owner)
3308
+ throw new Error("Human delivery ownership changed");
3309
+ // /raw retains exact bytes and never acquires a positive verification capability.
3310
+ if (payload.type === "raw_paste" || String(payload.content ?? "").startsWith("/raw ")) {
3311
+ this.deliveryOutbox.markHumanReceiptUncertain(row.receipt_id, instanceName);
3312
+ return payload;
3313
+ }
3314
+ return { ...payload, meta: { ...meta, human_owner_key: owner, human_daemon_owner: daemonOwner, human_wire_sha256: row.wire_sha256 } };
3315
+ }
3316
+ async deliverHumanToInstance(instanceName, payload, origin, originalBody, options) {
3317
+ const admitted = this.humanReceiptPayload(instanceName, payload, origin, originalBody);
3318
+ const id = admitted.meta?.human_receipt_id;
3319
+ let sent = false;
3320
+ try {
3321
+ const result = options ? await this.deliverToInstance(instanceName, admitted, options) : await this.deliverToInstance(instanceName, admitted);
3322
+ sent = result !== false;
3323
+ return result;
3324
+ }
3325
+ finally {
3326
+ if (!sent && id)
3327
+ this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
3328
+ }
3329
+ }
3330
+ /** Synchronous steer/btw handoff, using the same evidence and owner binding. */
3331
+ sendHumanControl(instanceName, payload, origin, originalBody) {
3332
+ const ipc = this.instanceIpcClients.get(instanceName);
3333
+ if (this.shuttingDown || !ipc?.connected)
3334
+ return false;
3335
+ const admitted = this.humanReceiptPayload(instanceName, payload, origin, originalBody);
3336
+ const id = admitted.meta?.human_receipt_id;
3337
+ let sent = false;
3338
+ try {
3339
+ sent = ipc.send(this.bindHumanReceiptPayload(instanceName, admitted));
3340
+ return sent;
3341
+ }
3342
+ finally {
3343
+ if (!sent && id)
3344
+ this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
3345
+ }
3346
+ }
3245
3347
  /** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
3246
3348
  async deliverToInstance(instanceName, payload, options = {}) {
3247
3349
  const deliveryEpoch = this.getDeliveryEpoch(instanceName);
@@ -3434,7 +3536,7 @@ export class FleetManager {
3434
3536
  userId: data.userId,
3435
3537
  username,
3436
3538
  source: "discord",
3437
- timestamp: new Date(),
3539
+ timestamp: new Date(), adapterId, humanOrigin: data.humanOrigin, humanOriginalBody: text,
3438
3540
  });
3439
3541
  }
3440
3542
  else if (data.command === "save") {
@@ -3579,7 +3681,8 @@ export class FleetManager {
3579
3681
  await data.respond(t("classic.no_agent"));
3580
3682
  return;
3581
3683
  }
3582
- const steerText = String(data.options?.message ?? "").trim();
3684
+ const originalSteerText = String(data.options?.message ?? "");
3685
+ const steerText = originalSteerText.trim();
3583
3686
  if (!steerText) {
3584
3687
  await data.respond(t("steer.usage"));
3585
3688
  return;
@@ -3589,7 +3692,7 @@ export class FleetManager {
3589
3692
  // rerouting the instance's replies to the slash context.
3590
3693
  const result = this.topicCommands.sendSteer(name, steerText, {
3591
3694
  chatId: "", messageId: "", username: data.username ?? "user",
3592
- userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
3695
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord", humanOrigin: data.humanOrigin, humanOriginalBody: originalSteerText,
3593
3696
  });
3594
3697
  await data.respond(result);
3595
3698
  }
@@ -3599,14 +3702,15 @@ export class FleetManager {
3599
3702
  await data.respond(t("classic.no_agent"));
3600
3703
  return;
3601
3704
  }
3602
- const btwText = String(data.options?.message ?? "").trim();
3705
+ const originalBtwText = String(data.options?.message ?? "");
3706
+ const btwText = originalBtwText.trim();
3603
3707
  if (!btwText) {
3604
3708
  await data.respond(t("btw.usage"));
3605
3709
  return;
3606
3710
  }
3607
3711
  const result = this.topicCommands.sendBtw(name, btwText, {
3608
3712
  chatId: "", messageId: "", username: data.username ?? "user",
3609
- userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
3713
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord", humanOrigin: data.humanOrigin, humanOriginalBody: originalBtwText,
3610
3714
  });
3611
3715
  await data.respond(result);
3612
3716
  }
@@ -4959,13 +5063,13 @@ export class FleetManager {
4959
5063
  if (result && !result.durable)
4960
5064
  this.logger.warn("Public session revocation could not be saved; exposure remains closed");
4961
5065
  },
4962
- log: (event, exposureId) => this.logger.info({ event, exposureId }, "Public web link"),
5066
+ log: (event, exposureId, detail) => this.logger.info({ event, exposureId, ...(detail ? { detail } : {}) }, "Public web link"),
4963
5067
  createPreview: ({ id, expiresAt, binary, protocol, dashboardOrigin, isCurrent }) => {
4964
5068
  if (!publicPreviewWanted(this.fleetConfig?.web))
4965
5069
  return null;
4966
5070
  return new PublicPreview({ exposureId: id, expiresAt, dashboardOrigin, isCurrent, manager: this.getPreviewTunnel(),
4967
5071
  provider: () => new CloudflaredProvider({ binaryName: binary, protocol }),
4968
- log: event => this.logger.info({ event, exposureId: id }, "Public web link") });
5072
+ log: (event, detail) => this.logger.info({ event, exposureId: id, ...(detail ? { detail } : {}) }, "Public web link") });
4969
5073
  },
4970
5074
  onCleanupUnconfirmed: (result, owner) => {
4971
5075
  this.logger.warn({ pid: result.confirmed ? null : result.pid }, "Public web access closed; tunnel cleanup unconfirmed");
@@ -7509,6 +7613,7 @@ export class FleetManager {
7509
7613
  });
7510
7614
  }
7511
7615
  async handleInboundMessage(msg) {
7616
+ msg = { ...msg, humanOriginalBody: msg.humanOriginalBody ?? msg.text, humanOrigin: msg.isBotMessage ? undefined : msg.humanOrigin && { ...msg.humanOrigin } };
7512
7617
  if (msg.isBotMessage)
7513
7618
  return withSpawnHoldReply(undefined, () => this.handleInboundMessageCore(msg));
7514
7619
  return withSpawnHoldReply(text => this.inboundNoticeAdapter(msg)?.sendText(msg.chatId, text, { threadId: msg.threadId }) ?? Promise.resolve(), () => this.handleInboundMessageCore(msg));
@@ -7967,7 +8072,7 @@ export class FleetManager {
7967
8072
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
7968
8073
  const generalReactions = this.pendingReactionsMeta(generalInstance);
7969
8074
  try {
7970
- await this.deliverToInstance(generalInstance, {
8075
+ await this.deliverHumanToInstance(generalInstance, {
7971
8076
  type: "fleet_inbound",
7972
8077
  content: text,
7973
8078
  targetSession: generalInstance,
@@ -7988,7 +8093,7 @@ export class FleetManager {
7988
8093
  ...generalReactions.meta,
7989
8094
  ...extraMeta,
7990
8095
  },
7991
- });
8096
+ }, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
7992
8097
  generalReactions.consume();
7993
8098
  this.afterUserInboundDelivered(generalInstance, msg);
7994
8099
  this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
@@ -8068,7 +8173,7 @@ export class FleetManager {
8068
8173
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, instanceName);
8069
8174
  const reactions = this.pendingReactionsMeta(instanceName);
8070
8175
  try {
8071
- await this.deliverToInstance(instanceName, {
8176
+ await this.deliverHumanToInstance(instanceName, {
8072
8177
  type: "fleet_inbound",
8073
8178
  content: text,
8074
8179
  targetSession: instanceName, // Channel messages → instance's own session
@@ -8087,7 +8192,7 @@ export class FleetManager {
8087
8192
  ...reactions.meta,
8088
8193
  ...extraMeta,
8089
8194
  },
8090
- });
8195
+ }, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
8091
8196
  // Only after the message actually went out. A failed delivery keeps the
8092
8197
  // reactions queued for the retry / the next message.
8093
8198
  reactions.consume();
@@ -8395,7 +8500,7 @@ export class FleetManager {
8395
8500
  // an agent cannot invent it to suppress the normal completion marker.
8396
8501
  if (!error && result != null && msg.statusOnly !== true) {
8397
8502
  try {
8398
- this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
8503
+ void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
8399
8504
  }
8400
8505
  catch (err) {
8401
8506
  this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
@@ -8448,6 +8553,7 @@ export class FleetManager {
8448
8553
  webOnlyPromptPlace() {
8449
8554
  return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
8450
8555
  }
8556
+ /** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
8451
8557
  afterReplyRouted(instanceName, args, senderSessionName, buttons = null) {
8452
8558
  // A reply is NOT proof the turn is over (#410) — but it is not proof of
8453
8559
  // more work either. Split the difference: an instance that is clearly
@@ -8465,27 +8571,74 @@ export class FleetManager {
8465
8571
  this.reactDone(instanceName);
8466
8572
  const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
8467
8573
  this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
8468
- // Files the agent attached are shown in the web chat too: registered for fetching by id (the path
8469
- // already passed the reply tool's sendability check, and is resolved once more here), never by path.
8470
- const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
8471
- const attachments = replyFiles
8472
- .map(path => this.webFiles.registerServed({ path, instance: instanceName }))
8473
- .filter((f) => f !== null)
8474
- .map(publicAttachment);
8475
- // The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
8476
- // role that may get HTML preview cards. Never inferred from the sender name or the text.
8477
- this.emitSseEvent("message", {
8478
- instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
8479
- text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
8480
- ts: new Date().toISOString(),
8481
- ...(attachments.length ? { attachments } : {}),
8482
- ...(buttons ? { buttons } : {}), // #1266
8483
- });
8484
8574
  // Log bot reply to classic instance chat-log
8485
8575
  const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
8486
8576
  if (isClassic) {
8487
8577
  ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
8488
8578
  }
8579
+ // Files the agent attached are shown in the web chat too, fetched by id, never by path. #1770: each is copied
8580
+ // first (web-reply-files.ts), and the copy is what the chat serves and keeps — so the message is shown once the
8581
+ // copies are done, and an instance's replies wait for the one before them: they reach the chat in order.
8582
+ const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
8583
+ const ts = new Date().toISOString();
8584
+ const show = (attachments) => {
8585
+ // The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
8586
+ // role that may get HTML preview cards. Never inferred from the sender name or the text.
8587
+ this.emitSseEvent("message", {
8588
+ instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
8589
+ text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
8590
+ ts,
8591
+ ...(attachments.length ? { attachments } : {}),
8592
+ ...(buttons ? { buttons } : {}), // #1266
8593
+ });
8594
+ };
8595
+ const before = this.webReplyOrder.get(instanceName);
8596
+ if (!replyFiles.length && !before) {
8597
+ show([]);
8598
+ return Promise.resolve();
8599
+ }
8600
+ const done = (before ?? Promise.resolve())
8601
+ .then(() => this.webReplyAttachments(instanceName, replyFiles))
8602
+ .catch((err) => { this.logger.warn({ err, instanceName }, "Reply files could not be prepared for the web chat"); return []; })
8603
+ .then((attachments) => { try {
8604
+ show(attachments);
8605
+ }
8606
+ catch (err) {
8607
+ this.logger.warn({ err, instanceName }, "Reply could not be shown in the web chat");
8608
+ } })
8609
+ .finally(() => { if (this.webReplyOrder.get(instanceName) === done)
8610
+ this.webReplyOrder.delete(instanceName); });
8611
+ this.webReplyOrder.set(instanceName, done);
8612
+ return done;
8613
+ }
8614
+ /**
8615
+ * #1770: a reply's files as the web chat shows them. Each is copied into the instance's web-files/ and served from
8616
+ * the copy. One the copy refuses (a symlink, not a regular file, over 50 MB, a state file, missing) is not shown;
8617
+ * one that could not be copied (I/O) is served from the agent's own path, as before — until the fleet restarts.
8618
+ */
8619
+ async webReplyAttachments(instanceName, paths) {
8620
+ const home = getAgendHome();
8621
+ const dir = webFilesDir(home, instanceName);
8622
+ const out = [];
8623
+ for (const path of paths) {
8624
+ const id = randomBytes(16).toString("hex");
8625
+ const got = dir ? await snapshotReplyFile(path, dir, id, { stateDir: home, maxDirBytes: this.webReplyMaxBytes }) : null;
8626
+ if (got && "evicted" in got && got.evicted.length)
8627
+ this.webFiles.dropServed(got.evicted);
8628
+ let file = null;
8629
+ if (got?.ok)
8630
+ file = this.webFiles.registerServed({ id, path: got.path, name: basename(path), instance: instanceName, snapshot: true });
8631
+ else if (got?.refused)
8632
+ this.logger.info({ instanceName, file: basename(path), reason: got.reason }, "Reply file not shown in the web chat");
8633
+ else {
8634
+ if (got)
8635
+ this.logger.warn({ instanceName, file: basename(path), reason: got.reason }, "Reply file could not be copied for the web chat; served from its own path until a restart");
8636
+ file = this.webFiles.registerServed({ path, instance: instanceName });
8637
+ }
8638
+ if (file)
8639
+ out.push(publicAttachment(file));
8640
+ }
8641
+ return out;
8489
8642
  }
8490
8643
  // ===================== Scheduler =====================
8491
8644
  async handleScheduleTrigger(schedule, stableRunId = randomUUID(), retry) {
@@ -12254,7 +12407,7 @@ export class FleetManager {
12254
12407
  const topicId = this.fleetConfig?.instances[instance]?.topic_id;
12255
12408
  return {
12256
12409
  chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(), username: "web-user", userId: "web-user",
12257
- threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web",
12410
+ threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true),
12258
12411
  };
12259
12412
  };
12260
12413
  const choicesFromCache = (instance) => {
@@ -12285,8 +12438,8 @@ export class FleetManager {
12285
12438
  return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
12286
12439
  },
12287
12440
  cancel: (instance) => this.cancelInstance(instance),
12288
- steer: (instance, text) => this.topicCommands.sendSteer(instance, text, webMeta(instance)),
12289
- btw: (instance, text) => this.topicCommands.sendBtw(instance, text, webMeta(instance)),
12441
+ steer: (instance, text, originalBody) => this.topicCommands.sendSteer(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
12442
+ btw: (instance, text, originalBody) => this.topicCommands.sendBtw(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
12290
12443
  pauseWake: (instance, action) => this.topicCommands.runPauseWake(instance, action),
12291
12444
  save: (instance, filename) => this.topicCommands.sendSave(instance, filename),
12292
12445
  clearAsk: (instance) => {
@@ -14082,16 +14235,26 @@ export class FleetManager {
14082
14235
  try {
14083
14236
  const store = new WebChatDiskStore({ home: getAgendHome(), logger: this.logger, ownerOf: (name) => this.webChatOwner(name) });
14084
14237
  const names = new Set([...Object.keys(fleet.instances ?? {}), ...(this.classicChannels?.getAll().map(c => c.instanceName) ?? [])]);
14085
- // A kept message's files are served again under their ids only from the instance's own inbox, by the ledger's
14086
- // checks (web-upload.ts restoreServed); they leave the ledger with their message.
14238
+ // A kept message's files are served again under their ids only from the instance's own inbox or (#1770) its reply
14239
+ // snapshots, by the ledger's checks (web-upload.ts restoreServed); they leave the ledger with their message.
14087
14240
  this.webChatHistory.setFiles({
14088
14241
  storablePath: (id) => this.webFiles.storablePath(id),
14089
- restore: (entry, instance) => this.webFiles.restoreServed(entry, instance, join(getAgendHome(), "workspaces", instance, "inbox")),
14242
+ restore: (entry, instance) => this.webFiles.restoreServed(entry, instance, join(getAgendHome(), "workspaces", instance, "inbox"), webFilesDir(getAgendHome(), instance) ?? undefined),
14090
14243
  drop: (ids) => this.webFiles.dropServed(ids),
14091
14244
  });
14092
14245
  let restored = 0;
14093
14246
  for (const name of names)
14094
14247
  restored += this.webChatHistory.restore(name, store.load(name));
14248
+ // #1770: before any instance replies, the snapshots no restored message holds (it left the history while the
14249
+ // fleet was down, or a copy stopped midway) are removed.
14250
+ let swept = 0;
14251
+ for (const name of names) {
14252
+ const dir = webFilesDir(getAgendHome(), name);
14253
+ if (dir)
14254
+ swept += sweepReplySnapshots(dir, this.webFiles.heldSnapshots(name));
14255
+ }
14256
+ if (swept)
14257
+ this.logger.info({ swept }, "Removed web reply snapshots no message holds");
14095
14258
  this.webChatStore = store;
14096
14259
  this.webChatHistory.setStore(store);
14097
14260
  if (restored)
@@ -14882,12 +15045,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14882
15045
  const reactions = this.pendingReactionsMeta(instanceName);
14883
15046
  Object.assign(meta, reactions.meta);
14884
15047
  try {
14885
- await this.deliverToInstance(instanceName, {
15048
+ await this.deliverHumanToInstance(instanceName, {
14886
15049
  type: "fleet_inbound",
14887
15050
  content: fullText,
14888
15051
  targetSession: instanceName,
14889
15052
  meta,
14890
- });
15053
+ }, msg.humanOrigin, msg.humanOriginalBody ?? text);
14891
15054
  reactions.consume();
14892
15055
  }
14893
15056
  catch (err) {