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

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 (134) 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/agent-whats-new.js +9 -3
  5. package/dist/agent-whats-new.js.map +1 -1
  6. package/dist/backend/kiro.d.ts +9 -0
  7. package/dist/backend/kiro.js +23 -0
  8. package/dist/backend/kiro.js.map +1 -1
  9. package/dist/backend/muse.d.ts +26 -0
  10. package/dist/backend/muse.js +131 -0
  11. package/dist/backend/muse.js.map +1 -1
  12. package/dist/channel/adapters/discord.js +12 -1
  13. package/dist/channel/adapters/discord.js.map +1 -1
  14. package/dist/channel/adapters/telegram.js +6 -0
  15. package/dist/channel/adapters/telegram.js.map +1 -1
  16. package/dist/channel/mcp-tools.js +3 -3
  17. package/dist/channel/mcp-tools.js.map +1 -1
  18. package/dist/channel/types.d.ts +3 -0
  19. package/dist/config-validator.js +3 -0
  20. package/dist/config-validator.js.map +1 -1
  21. package/dist/cross-instance-envelope.js +2 -0
  22. package/dist/cross-instance-envelope.js.map +1 -1
  23. package/dist/daemon.d.ts +8 -0
  24. package/dist/daemon.js +126 -54
  25. package/dist/daemon.js.map +1 -1
  26. package/dist/delivery-outbox.d.ts +27 -0
  27. package/dist/delivery-outbox.js +175 -6
  28. package/dist/delivery-outbox.js.map +1 -1
  29. package/dist/fleet-manager.d.ts +40 -1
  30. package/dist/fleet-manager.js +248 -40
  31. package/dist/fleet-manager.js.map +1 -1
  32. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +16 -1
  33. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +4 -0
  34. package/dist/human-delivery-receipts.d.ts +83 -0
  35. package/dist/human-delivery-receipts.js +194 -0
  36. package/dist/human-delivery-receipts.js.map +1 -0
  37. package/dist/instance-config-impact.js +1 -0
  38. package/dist/instance-config-impact.js.map +1 -1
  39. package/dist/instructions.d.ts +1 -1
  40. package/dist/instructions.js +1 -1
  41. package/dist/instructions.js.map +1 -1
  42. package/dist/locale.js +8 -0
  43. package/dist/locale.js.map +1 -1
  44. package/dist/log-service.d.ts +99 -0
  45. package/dist/log-service.js +235 -0
  46. package/dist/log-service.js.map +1 -0
  47. package/dist/log-steps.d.ts +143 -0
  48. package/dist/log-steps.js +646 -0
  49. package/dist/log-steps.js.map +1 -0
  50. package/dist/outbound-handlers.d.ts +3 -0
  51. package/dist/outbound-handlers.js +20 -4
  52. package/dist/outbound-handlers.js.map +1 -1
  53. package/dist/outbound-schemas.d.ts +15 -13
  54. package/dist/outbound-schemas.js +5 -0
  55. package/dist/outbound-schemas.js.map +1 -1
  56. package/dist/public-link-progress.d.ts +1 -1
  57. package/dist/public-link-progress.js +8 -2
  58. package/dist/public-link-progress.js.map +1 -1
  59. package/dist/public-preview.d.ts +2 -1
  60. package/dist/public-preview.js +8 -5
  61. package/dist/public-preview.js.map +1 -1
  62. package/dist/public-web-gateway.js +1 -1
  63. package/dist/public-web-gateway.js.map +1 -1
  64. package/dist/public-web-link.d.ts +4 -1
  65. package/dist/public-web-link.js +42 -13
  66. package/dist/public-web-link.js.map +1 -1
  67. package/dist/reply-buttons-controller.d.ts +3 -0
  68. package/dist/reply-buttons-controller.js +3 -2
  69. package/dist/reply-buttons-controller.js.map +1 -1
  70. package/dist/settings-api.js +4 -0
  71. package/dist/settings-api.js.map +1 -1
  72. package/dist/settings-change.js +2 -0
  73. package/dist/settings-change.js.map +1 -1
  74. package/dist/settings-effect.js +2 -0
  75. package/dist/settings-effect.js.map +1 -1
  76. package/dist/tool-progress.js +3 -2
  77. package/dist/tool-progress.js.map +1 -1
  78. package/dist/topic-commands.d.ts +3 -2
  79. package/dist/topic-commands.js +14 -4
  80. package/dist/topic-commands.js.map +1 -1
  81. package/dist/transcript-jsonl.d.ts +80 -3
  82. package/dist/transcript-jsonl.js +147 -10
  83. package/dist/transcript-jsonl.js.map +1 -1
  84. package/dist/transcript-monitor.d.ts +11 -0
  85. package/dist/transcript-monitor.js +61 -13
  86. package/dist/transcript-monitor.js.map +1 -1
  87. package/dist/transcript-sources.d.ts +24 -0
  88. package/dist/transcript-sources.js +205 -55
  89. package/dist/transcript-sources.js.map +1 -1
  90. package/dist/tunnel/cloudflared.d.ts +7 -15
  91. package/dist/tunnel/cloudflared.js +36 -1
  92. package/dist/tunnel/cloudflared.js.map +1 -1
  93. package/dist/tunnel/types.d.ts +5 -1
  94. package/dist/tunnel/types.js.map +1 -1
  95. package/dist/types.d.ts +6 -0
  96. package/dist/ui/chat-strings.js +8 -2
  97. package/dist/ui/chat-thread.js +24 -4
  98. package/dist/ui/chat-time.js +80 -0
  99. package/dist/ui/panel-log.js +388 -0
  100. package/dist/ui/panel-settings.js +6 -1
  101. package/dist/ui/preview-panel.js +7 -1
  102. package/dist/ui/preview.js +5 -1
  103. package/dist/ui/settings-strings.js +2 -0
  104. package/dist/ui/shared/app-i18n.js +2 -2
  105. package/dist/ui/shared/app-route.js +6 -1
  106. package/dist/ui/shared/app-shell.js +3 -3
  107. package/dist/ui/shared/app-stream.js +2 -1
  108. package/dist/ui/shared/app.css +44 -6
  109. package/dist/ui/shared/app.js +5 -0
  110. package/dist/ui/shared/fonts/README.md +1 -1
  111. package/dist/ui/shared/instance-nav.js +9 -6
  112. package/dist/ui/shared/instance-switch.js +4 -4
  113. package/dist/ui/shared/tokens.css +1 -1
  114. package/dist/web-api.d.ts +5 -0
  115. package/dist/web-api.js +49 -1
  116. package/dist/web-api.js.map +1 -1
  117. package/dist/web-auth.js +2 -1
  118. package/dist/web-auth.js.map +1 -1
  119. package/dist/web-commands.d.ts +2 -2
  120. package/dist/web-commands.js +3 -2
  121. package/dist/web-commands.js.map +1 -1
  122. package/dist/web-reply-files.d.ts +45 -0
  123. package/dist/web-reply-files.js +223 -0
  124. package/dist/web-reply-files.js.map +1 -0
  125. package/dist/web-settings-patch.d.ts +3 -1
  126. package/dist/web-settings-patch.js +4 -1
  127. package/dist/web-settings-patch.js.map +1 -1
  128. package/dist/web-shell-routes.d.ts +4 -0
  129. package/dist/web-shell-routes.js +9 -0
  130. package/dist/web-shell-routes.js.map +1 -1
  131. package/dist/web-upload.d.ts +17 -4
  132. package/dist/web-upload.js +64 -13
  133. package/dist/web-upload.js.map +1 -1
  134. 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";
@@ -52,6 +53,7 @@ export declare function applyTaskListCap<T extends {
52
53
  hint: string;
53
54
  } | T[];
54
55
  import { type PublicPreviewForUi } from "./web-api.js";
56
+ import { type LogAnswer, type LogQuery } from "./log-service.js";
55
57
  import { WebChatHistory } from "./web-chat-history.js";
56
58
  import { SeenUsers, type MentionNames, type MentionQuery } from "./web-mentions.js";
57
59
  import { WebPrefsStore } from "./web-prefs.js";
@@ -63,6 +65,7 @@ import { type AgentEndpointContext } from "./agent-endpoint.js";
63
65
  import { ClassicChannelManager } from "./classic-channel-manager.js";
64
66
  import { type ExplicitInstanceRemoval } from "./instance-removal.js";
65
67
  import type { InstanceState, InteractionSnapshot } from "./backend/types.js";
68
+ import { type Daemon } from "./daemon.js";
66
69
  import { WakeCoordinator } from "./wake-coordinator.js";
67
70
  import { TargetQueueWorker } from "./target-queue-worker.js";
68
71
  import { resolveDeliveryWorkerMode } from "./types.js";
@@ -218,7 +221,7 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
218
221
  private replyObligationTimer;
219
222
  private readonly activeDurableTargets;
220
223
  /** Live view of lifecycle.daemons — used throughout; not deprecated. */
221
- get daemons(): Map<string, import("./daemon.js").Daemon>;
224
+ get daemons(): Map<string, Daemon>;
222
225
  fleetConfig: FleetConfig | null;
223
226
  private rawFleetConfig;
224
227
  private rawFleetDocument;
@@ -470,6 +473,10 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
470
473
  private webChatStore;
471
474
  /** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
472
475
  readonly webFiles: WebFileLedger;
476
+ /** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
477
+ private readonly webReplyOrder;
478
+ /** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
479
+ private webReplyMaxBytes;
473
480
  /** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
474
481
  readonly seenUsers: SeenUsers;
475
482
  private webPrefsStore?;
@@ -555,10 +562,13 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
555
562
  kind: string;
556
563
  correlationId: string;
557
564
  payload: Record<string, unknown>;
565
+ forwards?: readonly string[];
566
+ requestedKind?: string;
558
567
  }): {
559
568
  deliveryId: string;
560
569
  state: string;
561
570
  duplicate: boolean;
571
+ kind: string;
562
572
  };
563
573
  queryDurableDeliveryStatus(callerInstance: string, selector: DeliveryStatusSelector): DeliveryStatusPage;
564
574
  /** #1641: why a target is not taking queued work right now, or null when nothing known holds it. */
@@ -805,6 +815,17 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
805
815
  getActiveBackendIds(): ReadonlySet<string>;
806
816
  isClassicInstance(name: string): boolean;
807
817
  /** #1523 N2: a ClassicBot room's own summary for its Details page (classicBot.yaml), or null. */
818
+ /** #1218: the Log tab's on-demand service — made on first use; with `web.log_tab` off it answers off and does nothing. */
819
+ private logService;
820
+ private get log();
821
+ /** #1218: GET /ui/steps — an instance's steps, read on demand (null: no such instance). */
822
+ logSteps(name: string, q: LogQuery): Promise<LogAnswer | null>;
823
+ /**
824
+ * #1218: where an instance's steps are read from, resolved only when its Log asks: the transcript its daemon follows
825
+ * now; for claude-code, also the path its statusline last named (so a stopped instance's Log still shows its last
826
+ * work). kiro comes with its own reader later in phase 1; other backends have no transcript AgEnD can read.
827
+ */
828
+ private logSource;
808
829
  classicRoomFor(name: string): {
809
830
  name: string;
810
831
  channel_id: string;
@@ -902,6 +923,17 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
902
923
  hasPendingIdleGatedDelivery(instanceName: string): boolean;
903
924
  /** The running launch's own steer capability (#1405); undefined for a backend without one, or no running Daemon. */
904
925
  instanceLaunchSupportsSteer(instanceName: string): boolean | undefined;
926
+ private humanReceiptDaemons;
927
+ /** Cached FM + lifecycle + Daemon + tmux owner; unknown ownership grants nothing. */
928
+ private humanReceiptOwner;
929
+ private listenForHumanReceipt;
930
+ /** Evidence-only admission. Bots/system/agent payloads have no trusted origin. */
931
+ private humanReceiptPayload;
932
+ /** Bind at the actual IPC send, after all wake/reconnect waits. No human retry lane. */
933
+ private bindHumanReceiptPayload;
934
+ deliverHumanToInstance(instanceName: string, payload: Record<string, unknown>, origin: HumanOrigin | undefined, originalBody: string, options?: DeliveryOptions): Promise<boolean | void>;
935
+ /** Synchronous steer/btw handoff, using the same evidence and owner binding. */
936
+ sendHumanControl(instanceName: string, payload: Record<string, unknown>, origin: HumanOrigin | undefined, originalBody: string): boolean;
905
937
  /** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
906
938
  deliverToInstance(instanceName: string, payload: Record<string, unknown>, options?: DeliveryOptions): Promise<boolean | void>;
907
939
  /**
@@ -1580,7 +1612,14 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1580
1612
  private isWebOnlyFleet;
1581
1613
  /** On a fleet with no chat platform, the dashboard is where an instance-health prompt is posted (#1307 item 6). */
1582
1614
  private webOnlyPromptPlace;
1615
+ /** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
1583
1616
  private afterReplyRouted;
1617
+ /**
1618
+ * #1770: a reply's files as the web chat shows them. Each is copied into the instance's web-files/ and served from
1619
+ * the copy. One the copy refuses (a symlink, not a regular file, over 50 MB, a state file, missing) is not shown;
1620
+ * one that could not be copied (I/O) is served from the agent's own path, as before — until the fleet restarts.
1621
+ */
1622
+ private webReplyAttachments;
1584
1623
  private handleScheduleTrigger;
1585
1624
  /**
1586
1625
  * 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";
@@ -131,6 +132,7 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
131
132
  return tasks;
132
133
  }
133
134
  import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
135
+ import { LogService } from "./log-service.js";
134
136
  import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
135
137
  import { WebChatDiskStore } from "./web-chat-store.js";
136
138
  import { SeenUsers, resolveMentions } from "./web-mentions.js";
@@ -138,6 +140,7 @@ import { WebPrefsStore } from "./web-prefs.js";
138
140
  import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
139
141
  import { ReplyButtonsController } from "./reply-buttons-controller.js";
140
142
  import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
143
+ import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
141
144
  import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
142
145
  import { downloadStickerImage } from "./channel/sticker-download.js";
143
146
  import { WebImageCache, imageMimeForFile, WEB_IMAGE_MAX_AGE_MS, WEB_IMAGE_MAX_BYTES, WEB_IMAGE_RETRY_MS } from "./web-images.js";
@@ -789,6 +792,10 @@ export class FleetManager {
789
792
  webChatStore = null;
790
793
  /** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
791
794
  webFiles = new WebFileLedger();
795
+ /** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
796
+ webReplyOrder = new Map();
797
+ /** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
798
+ webReplyMaxBytes = REPLY_FILES_MAX_BYTES;
792
799
  /** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
793
800
  seenUsers = new SeenUsers();
794
801
  webPrefsStore;
@@ -1189,7 +1196,7 @@ export class FleetManager {
1189
1196
  const reactions = this.pendingReactionsMeta(set.instance);
1190
1197
  let accepted = false;
1191
1198
  try {
1192
- accepted = (await this.deliverToInstance(set.instance, {
1199
+ accepted = (await this.deliverHumanToInstance(set.instance, {
1193
1200
  type: "fleet_inbound",
1194
1201
  content,
1195
1202
  targetSession: set.instance,
@@ -1198,7 +1205,7 @@ export class FleetManager {
1198
1205
  thread_id: set.threadId, adapter_id: inbound.adapterId, source: inbound.source,
1199
1206
  ...reactions.meta,
1200
1207
  },
1201
- })) !== false;
1208
+ }, by.humanOrigin, button.label)) !== false;
1202
1209
  }
1203
1210
  finally {
1204
1211
  // Both false and throw reopen the choice in the controller. Finish the
@@ -1317,6 +1324,7 @@ export class FleetManager {
1317
1324
  deliveryId: admitted.delivery.deliveryId,
1318
1325
  state: admitted.delivery.state,
1319
1326
  duplicate: !admitted.inserted,
1327
+ kind: admitted.delivery.kind,
1320
1328
  };
1321
1329
  }
1322
1330
  queryDurableDeliveryStatus(callerInstance, selector) {
@@ -2688,6 +2696,49 @@ export class FleetManager {
2688
2696
  return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
2689
2697
  }
2690
2698
  /** #1523 N2: a ClassicBot room's own summary for its Details page (classicBot.yaml), or null. */
2699
+ /** #1218: the Log tab's on-demand service — made on first use; with `web.log_tab` off it answers off and does nothing. */
2700
+ logService = null;
2701
+ get log() {
2702
+ return this.logService ??= new LogService({
2703
+ enabled: () => this.fleetConfig?.web?.log_tab === true,
2704
+ known: (name) => Object.prototype.hasOwnProperty.call(this.fleetConfig?.instances ?? {}, name) || this.classicRoomFor(name) !== null,
2705
+ source: (name) => this.logSource(name),
2706
+ events: (name, limit) => (this.eventLog?.query({ instance: name, limit }) ?? []),
2707
+ emit: (event, data) => this.emitSseEvent(event, data),
2708
+ });
2709
+ }
2710
+ /** #1218: GET /ui/steps — an instance's steps, read on demand (null: no such instance). */
2711
+ logSteps(name, q) { return this.log.steps(name, q); }
2712
+ /**
2713
+ * #1218: where an instance's steps are read from, resolved only when its Log asks: the transcript its daemon follows
2714
+ * now; for claude-code, also the path its statusline last named (so a stopped instance's Log still shows its last
2715
+ * work). kiro comes with its own reader later in phase 1; other backends have no transcript AgEnD can read.
2716
+ */
2717
+ logSource(name) {
2718
+ const instances = this.fleetConfig?.instances ?? {};
2719
+ const config = Object.prototype.hasOwnProperty.call(instances, name) ? instances[name] : undefined;
2720
+ const backend = config?.backend ?? (config ? null : this.classicRoomFor(name)?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
2721
+ const live = this.lifecycle.daemons.get(name)?.logTranscriptPath() ?? null;
2722
+ if (backend === "claude-code") {
2723
+ let path = live;
2724
+ if (!path) {
2725
+ try {
2726
+ path = JSON.parse(readFileSync(join(this.getInstanceDir(name), "statusline.json"), "utf-8")).transcript_path ?? null;
2727
+ }
2728
+ catch {
2729
+ path = null;
2730
+ }
2731
+ if (typeof path !== "string" || !path)
2732
+ path = null;
2733
+ }
2734
+ return { backend, path, format: "claude", ...(path ? {} : { reason: "no_transcript" }) };
2735
+ }
2736
+ if (backend === "codex")
2737
+ return { backend, path: live, format: "codex", ...(live ? {} : { reason: "not_running" }) };
2738
+ if (backend === "kiro-cli")
2739
+ return { backend, path: null, format: null, reason: "coming" };
2740
+ return { backend, path: null, format: null, reason: "unsupported" };
2741
+ }
2691
2742
  classicRoomFor(name) {
2692
2743
  const ch = this.classicChannels?.getAll().find(channel => channel.instanceName === name);
2693
2744
  if (!ch)
@@ -2989,7 +3040,7 @@ export class FleetManager {
2989
3040
  const queued = this.ipcWaitTails.get(instanceName);
2990
3041
  if (!queued) {
2991
3042
  const ipc = this.instanceIpcClients.get(instanceName);
2992
- if (ipc?.connected && ipc.send(payload))
3043
+ if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
2993
3044
  return true;
2994
3045
  }
2995
3046
  const attempt = (queued ?? Promise.resolve())
@@ -3024,7 +3075,7 @@ export class FleetManager {
3024
3075
  // Re-read every round: a reconnect replaces the IpcClient object entirely,
3025
3076
  // so a cached reference would stay dead forever.
3026
3077
  const ipc = this.instanceIpcClients.get(instanceName);
3027
- if (ipc?.connected && ipc.send(payload))
3078
+ if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
3028
3079
  return true;
3029
3080
  if (Date.now() >= deadline) {
3030
3081
  throw new Error(`Instance '${instanceName}' IPC is unavailable`);
@@ -3242,6 +3293,101 @@ export class FleetManager {
3242
3293
  instanceLaunchSupportsSteer(instanceName) {
3243
3294
  return this.daemons.get(instanceName)?.launchSupportsSteer();
3244
3295
  }
3296
+ humanReceiptDaemons = new WeakSet();
3297
+ /** Cached FM + lifecycle + Daemon + tmux owner; unknown ownership grants nothing. */
3298
+ humanReceiptOwner(instanceName, daemon = this.daemons.get(instanceName)) {
3299
+ try {
3300
+ if (this.shuttingDown || !daemon || this.daemons.get(instanceName) !== daemon)
3301
+ return null;
3302
+ const epoch = this.lifecycle.epochOf(instanceName);
3303
+ const owner = daemon.captureHumanReceiptOwner();
3304
+ if (!Number.isSafeInteger(epoch) || !owner)
3305
+ return null;
3306
+ return JSON.stringify([this.managerBootId, epoch, this.getDeliveryEpoch(instanceName), humanOwnerObjectId(daemon), owner]);
3307
+ }
3308
+ catch {
3309
+ return null;
3310
+ }
3311
+ }
3312
+ listenForHumanReceipt(instanceName, daemon) {
3313
+ // Lazy initialization also supports narrow prototype-based integration fixtures.
3314
+ this.humanReceiptDaemons ??= new WeakSet();
3315
+ if (this.humanReceiptDaemons.has(daemon))
3316
+ return;
3317
+ this.humanReceiptDaemons.add(daemon);
3318
+ daemon.on("human_receipt_result", (result) => {
3319
+ try {
3320
+ this.deliveryOutbox?.settleHumanReceipt(result.receipt_id, instanceName, result.owner_key, result.proof, this.humanReceiptOwner(instanceName, daemon));
3321
+ }
3322
+ catch (err) {
3323
+ this.logger.error({ err, instanceName }, "Could not persist human delivery evidence");
3324
+ }
3325
+ });
3326
+ }
3327
+ /** Evidence-only admission. Bots/system/agent payloads have no trusted origin. */
3328
+ humanReceiptPayload(instanceName, payload, origin, originalBody) {
3329
+ if (!origin || !this.deliveryOutbox)
3330
+ return payload;
3331
+ const row = this.deliveryOutbox.createHumanReceipt({ targetInstance: instanceName, originalBody,
3332
+ origin: { ...origin }, inputKind: String(payload.type), managerBootId: this.managerBootId });
3333
+ return { ...payload, meta: { ...(payload.meta ?? {}),
3334
+ human_receipt_id: row.receipt_id, verification_id: row.message_id } };
3335
+ }
3336
+ /** Bind at the actual IPC send, after all wake/reconnect waits. No human retry lane. */
3337
+ bindHumanReceiptPayload(instanceName, payload) {
3338
+ const meta = payload.meta;
3339
+ if (!meta?.human_receipt_id || !this.deliveryOutbox)
3340
+ return payload;
3341
+ const daemon = this.daemons.get(instanceName);
3342
+ const owner = this.humanReceiptOwner(instanceName, daemon);
3343
+ if (!daemon || !owner) {
3344
+ this.deliveryOutbox.markHumanReceiptUncertain(meta.human_receipt_id, instanceName);
3345
+ return payload;
3346
+ }
3347
+ this.listenForHumanReceipt(instanceName, daemon);
3348
+ const daemonOwner = daemon.captureHumanReceiptOwner();
3349
+ const wire = daemon.humanReceiptWire(String(payload.content ?? ""), meta, String(payload.type));
3350
+ const row = this.deliveryOutbox.bindHumanReceipt(meta.human_receipt_id, instanceName, owner, wire, this.managerBootId);
3351
+ if (!daemonOwner || this.humanReceiptOwner(instanceName, daemon) !== owner)
3352
+ throw new Error("Human delivery ownership changed");
3353
+ // /raw retains exact bytes and never acquires a positive verification capability.
3354
+ if (payload.type === "raw_paste" || String(payload.content ?? "").startsWith("/raw ")) {
3355
+ this.deliveryOutbox.markHumanReceiptUncertain(row.receipt_id, instanceName);
3356
+ return payload;
3357
+ }
3358
+ return { ...payload, meta: { ...meta, human_owner_key: owner, human_daemon_owner: daemonOwner, human_wire_sha256: row.wire_sha256 } };
3359
+ }
3360
+ async deliverHumanToInstance(instanceName, payload, origin, originalBody, options) {
3361
+ const admitted = this.humanReceiptPayload(instanceName, payload, origin, originalBody);
3362
+ const id = admitted.meta?.human_receipt_id;
3363
+ let sent = false;
3364
+ try {
3365
+ const result = options ? await this.deliverToInstance(instanceName, admitted, options) : await this.deliverToInstance(instanceName, admitted);
3366
+ sent = result !== false;
3367
+ return result;
3368
+ }
3369
+ finally {
3370
+ if (!sent && id)
3371
+ this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
3372
+ }
3373
+ }
3374
+ /** Synchronous steer/btw handoff, using the same evidence and owner binding. */
3375
+ sendHumanControl(instanceName, payload, origin, originalBody) {
3376
+ const ipc = this.instanceIpcClients.get(instanceName);
3377
+ if (this.shuttingDown || !ipc?.connected)
3378
+ return false;
3379
+ const admitted = this.humanReceiptPayload(instanceName, payload, origin, originalBody);
3380
+ const id = admitted.meta?.human_receipt_id;
3381
+ let sent = false;
3382
+ try {
3383
+ sent = ipc.send(this.bindHumanReceiptPayload(instanceName, admitted));
3384
+ return sent;
3385
+ }
3386
+ finally {
3387
+ if (!sent && id)
3388
+ this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
3389
+ }
3390
+ }
3245
3391
  /** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
3246
3392
  async deliverToInstance(instanceName, payload, options = {}) {
3247
3393
  const deliveryEpoch = this.getDeliveryEpoch(instanceName);
@@ -3434,7 +3580,7 @@ export class FleetManager {
3434
3580
  userId: data.userId,
3435
3581
  username,
3436
3582
  source: "discord",
3437
- timestamp: new Date(),
3583
+ timestamp: new Date(), adapterId, humanOrigin: data.humanOrigin, humanOriginalBody: text,
3438
3584
  });
3439
3585
  }
3440
3586
  else if (data.command === "save") {
@@ -3579,7 +3725,8 @@ export class FleetManager {
3579
3725
  await data.respond(t("classic.no_agent"));
3580
3726
  return;
3581
3727
  }
3582
- const steerText = String(data.options?.message ?? "").trim();
3728
+ const originalSteerText = String(data.options?.message ?? "");
3729
+ const steerText = originalSteerText.trim();
3583
3730
  if (!steerText) {
3584
3731
  await data.respond(t("steer.usage"));
3585
3732
  return;
@@ -3589,7 +3736,7 @@ export class FleetManager {
3589
3736
  // rerouting the instance's replies to the slash context.
3590
3737
  const result = this.topicCommands.sendSteer(name, steerText, {
3591
3738
  chatId: "", messageId: "", username: data.username ?? "user",
3592
- userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
3739
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord", humanOrigin: data.humanOrigin, humanOriginalBody: originalSteerText,
3593
3740
  });
3594
3741
  await data.respond(result);
3595
3742
  }
@@ -3599,14 +3746,15 @@ export class FleetManager {
3599
3746
  await data.respond(t("classic.no_agent"));
3600
3747
  return;
3601
3748
  }
3602
- const btwText = String(data.options?.message ?? "").trim();
3749
+ const originalBtwText = String(data.options?.message ?? "");
3750
+ const btwText = originalBtwText.trim();
3603
3751
  if (!btwText) {
3604
3752
  await data.respond(t("btw.usage"));
3605
3753
  return;
3606
3754
  }
3607
3755
  const result = this.topicCommands.sendBtw(name, btwText, {
3608
3756
  chatId: "", messageId: "", username: data.username ?? "user",
3609
- userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
3757
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord", humanOrigin: data.humanOrigin, humanOriginalBody: originalBtwText,
3610
3758
  });
3611
3759
  await data.respond(result);
3612
3760
  }
@@ -4959,13 +5107,13 @@ export class FleetManager {
4959
5107
  if (result && !result.durable)
4960
5108
  this.logger.warn("Public session revocation could not be saved; exposure remains closed");
4961
5109
  },
4962
- log: (event, exposureId) => this.logger.info({ event, exposureId }, "Public web link"),
5110
+ log: (event, exposureId, detail) => this.logger.info({ event, exposureId, ...(detail ? { detail } : {}) }, "Public web link"),
4963
5111
  createPreview: ({ id, expiresAt, binary, protocol, dashboardOrigin, isCurrent }) => {
4964
5112
  if (!publicPreviewWanted(this.fleetConfig?.web))
4965
5113
  return null;
4966
5114
  return new PublicPreview({ exposureId: id, expiresAt, dashboardOrigin, isCurrent, manager: this.getPreviewTunnel(),
4967
5115
  provider: () => new CloudflaredProvider({ binaryName: binary, protocol }),
4968
- log: event => this.logger.info({ event, exposureId: id }, "Public web link") });
5116
+ log: (event, detail) => this.logger.info({ event, exposureId: id, ...(detail ? { detail } : {}) }, "Public web link") });
4969
5117
  },
4970
5118
  onCleanupUnconfirmed: (result, owner) => {
4971
5119
  this.logger.warn({ pid: result.confirmed ? null : result.pid }, "Public web access closed; tunnel cleanup unconfirmed");
@@ -7509,6 +7657,7 @@ export class FleetManager {
7509
7657
  });
7510
7658
  }
7511
7659
  async handleInboundMessage(msg) {
7660
+ msg = { ...msg, humanOriginalBody: msg.humanOriginalBody ?? msg.text, humanOrigin: msg.isBotMessage ? undefined : msg.humanOrigin && { ...msg.humanOrigin } };
7512
7661
  if (msg.isBotMessage)
7513
7662
  return withSpawnHoldReply(undefined, () => this.handleInboundMessageCore(msg));
7514
7663
  return withSpawnHoldReply(text => this.inboundNoticeAdapter(msg)?.sendText(msg.chatId, text, { threadId: msg.threadId }) ?? Promise.resolve(), () => this.handleInboundMessageCore(msg));
@@ -7967,7 +8116,7 @@ export class FleetManager {
7967
8116
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
7968
8117
  const generalReactions = this.pendingReactionsMeta(generalInstance);
7969
8118
  try {
7970
- await this.deliverToInstance(generalInstance, {
8119
+ await this.deliverHumanToInstance(generalInstance, {
7971
8120
  type: "fleet_inbound",
7972
8121
  content: text,
7973
8122
  targetSession: generalInstance,
@@ -7988,7 +8137,7 @@ export class FleetManager {
7988
8137
  ...generalReactions.meta,
7989
8138
  ...extraMeta,
7990
8139
  },
7991
- });
8140
+ }, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
7992
8141
  generalReactions.consume();
7993
8142
  this.afterUserInboundDelivered(generalInstance, msg);
7994
8143
  this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
@@ -8068,7 +8217,7 @@ export class FleetManager {
8068
8217
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, instanceName);
8069
8218
  const reactions = this.pendingReactionsMeta(instanceName);
8070
8219
  try {
8071
- await this.deliverToInstance(instanceName, {
8220
+ await this.deliverHumanToInstance(instanceName, {
8072
8221
  type: "fleet_inbound",
8073
8222
  content: text,
8074
8223
  targetSession: instanceName, // Channel messages → instance's own session
@@ -8087,7 +8236,7 @@ export class FleetManager {
8087
8236
  ...reactions.meta,
8088
8237
  ...extraMeta,
8089
8238
  },
8090
- });
8239
+ }, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
8091
8240
  // Only after the message actually went out. A failed delivery keeps the
8092
8241
  // reactions queued for the retry / the next message.
8093
8242
  reactions.consume();
@@ -8395,7 +8544,7 @@ export class FleetManager {
8395
8544
  // an agent cannot invent it to suppress the normal completion marker.
8396
8545
  if (!error && result != null && msg.statusOnly !== true) {
8397
8546
  try {
8398
- this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
8547
+ void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
8399
8548
  }
8400
8549
  catch (err) {
8401
8550
  this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
@@ -8448,6 +8597,7 @@ export class FleetManager {
8448
8597
  webOnlyPromptPlace() {
8449
8598
  return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
8450
8599
  }
8600
+ /** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
8451
8601
  afterReplyRouted(instanceName, args, senderSessionName, buttons = null) {
8452
8602
  // A reply is NOT proof the turn is over (#410) — but it is not proof of
8453
8603
  // more work either. Split the difference: an instance that is clearly
@@ -8465,27 +8615,74 @@ export class FleetManager {
8465
8615
  this.reactDone(instanceName);
8466
8616
  const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
8467
8617
  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
8618
  // Log bot reply to classic instance chat-log
8485
8619
  const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
8486
8620
  if (isClassic) {
8487
8621
  ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
8488
8622
  }
8623
+ // Files the agent attached are shown in the web chat too, fetched by id, never by path. #1770: each is copied
8624
+ // first (web-reply-files.ts), and the copy is what the chat serves and keeps — so the message is shown once the
8625
+ // copies are done, and an instance's replies wait for the one before them: they reach the chat in order.
8626
+ const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
8627
+ const ts = new Date().toISOString();
8628
+ const show = (attachments) => {
8629
+ // The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
8630
+ // role that may get HTML preview cards. Never inferred from the sender name or the text.
8631
+ this.emitSseEvent("message", {
8632
+ instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
8633
+ text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
8634
+ ts,
8635
+ ...(attachments.length ? { attachments } : {}),
8636
+ ...(buttons ? { buttons } : {}), // #1266
8637
+ });
8638
+ };
8639
+ const before = this.webReplyOrder.get(instanceName);
8640
+ if (!replyFiles.length && !before) {
8641
+ show([]);
8642
+ return Promise.resolve();
8643
+ }
8644
+ const done = (before ?? Promise.resolve())
8645
+ .then(() => this.webReplyAttachments(instanceName, replyFiles))
8646
+ .catch((err) => { this.logger.warn({ err, instanceName }, "Reply files could not be prepared for the web chat"); return []; })
8647
+ .then((attachments) => { try {
8648
+ show(attachments);
8649
+ }
8650
+ catch (err) {
8651
+ this.logger.warn({ err, instanceName }, "Reply could not be shown in the web chat");
8652
+ } })
8653
+ .finally(() => { if (this.webReplyOrder.get(instanceName) === done)
8654
+ this.webReplyOrder.delete(instanceName); });
8655
+ this.webReplyOrder.set(instanceName, done);
8656
+ return done;
8657
+ }
8658
+ /**
8659
+ * #1770: a reply's files as the web chat shows them. Each is copied into the instance's web-files/ and served from
8660
+ * the copy. One the copy refuses (a symlink, not a regular file, over 50 MB, a state file, missing) is not shown;
8661
+ * one that could not be copied (I/O) is served from the agent's own path, as before — until the fleet restarts.
8662
+ */
8663
+ async webReplyAttachments(instanceName, paths) {
8664
+ const home = getAgendHome();
8665
+ const dir = webFilesDir(home, instanceName);
8666
+ const out = [];
8667
+ for (const path of paths) {
8668
+ const id = randomBytes(16).toString("hex");
8669
+ const got = dir ? await snapshotReplyFile(path, dir, id, { stateDir: home, maxDirBytes: this.webReplyMaxBytes }) : null;
8670
+ if (got && "evicted" in got && got.evicted.length)
8671
+ this.webFiles.dropServed(got.evicted);
8672
+ let file = null;
8673
+ if (got?.ok)
8674
+ file = this.webFiles.registerServed({ id, path: got.path, name: basename(path), instance: instanceName, snapshot: true });
8675
+ else if (got?.refused)
8676
+ this.logger.info({ instanceName, file: basename(path), reason: got.reason }, "Reply file not shown in the web chat");
8677
+ else {
8678
+ if (got)
8679
+ 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");
8680
+ file = this.webFiles.registerServed({ path, instance: instanceName });
8681
+ }
8682
+ if (file)
8683
+ out.push(publicAttachment(file));
8684
+ }
8685
+ return out;
8489
8686
  }
8490
8687
  // ===================== Scheduler =====================
8491
8688
  async handleScheduleTrigger(schedule, stableRunId = randomUUID(), retry) {
@@ -12254,7 +12451,7 @@ export class FleetManager {
12254
12451
  const topicId = this.fleetConfig?.instances[instance]?.topic_id;
12255
12452
  return {
12256
12453
  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",
12454
+ threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true),
12258
12455
  };
12259
12456
  };
12260
12457
  const choicesFromCache = (instance) => {
@@ -12285,8 +12482,8 @@ export class FleetManager {
12285
12482
  return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
12286
12483
  },
12287
12484
  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)),
12485
+ steer: (instance, text, originalBody) => this.topicCommands.sendSteer(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
12486
+ btw: (instance, text, originalBody) => this.topicCommands.sendBtw(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
12290
12487
  pauseWake: (instance, action) => this.topicCommands.runPauseWake(instance, action),
12291
12488
  save: (instance, filename) => this.topicCommands.sendSave(instance, filename),
12292
12489
  clearAsk: (instance) => {
@@ -14082,16 +14279,26 @@ export class FleetManager {
14082
14279
  try {
14083
14280
  const store = new WebChatDiskStore({ home: getAgendHome(), logger: this.logger, ownerOf: (name) => this.webChatOwner(name) });
14084
14281
  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.
14282
+ // A kept message's files are served again under their ids only from the instance's own inbox or (#1770) its reply
14283
+ // snapshots, by the ledger's checks (web-upload.ts restoreServed); they leave the ledger with their message.
14087
14284
  this.webChatHistory.setFiles({
14088
14285
  storablePath: (id) => this.webFiles.storablePath(id),
14089
- restore: (entry, instance) => this.webFiles.restoreServed(entry, instance, join(getAgendHome(), "workspaces", instance, "inbox")),
14286
+ restore: (entry, instance) => this.webFiles.restoreServed(entry, instance, join(getAgendHome(), "workspaces", instance, "inbox"), webFilesDir(getAgendHome(), instance) ?? undefined),
14090
14287
  drop: (ids) => this.webFiles.dropServed(ids),
14091
14288
  });
14092
14289
  let restored = 0;
14093
14290
  for (const name of names)
14094
14291
  restored += this.webChatHistory.restore(name, store.load(name));
14292
+ // #1770: before any instance replies, the snapshots no restored message holds (it left the history while the
14293
+ // fleet was down, or a copy stopped midway) are removed.
14294
+ let swept = 0;
14295
+ for (const name of names) {
14296
+ const dir = webFilesDir(getAgendHome(), name);
14297
+ if (dir)
14298
+ swept += sweepReplySnapshots(dir, this.webFiles.heldSnapshots(name));
14299
+ }
14300
+ if (swept)
14301
+ this.logger.info({ swept }, "Removed web reply snapshots no message holds");
14095
14302
  this.webChatStore = store;
14096
14303
  this.webChatHistory.setStore(store);
14097
14304
  if (restored)
@@ -14882,12 +15089,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14882
15089
  const reactions = this.pendingReactionsMeta(instanceName);
14883
15090
  Object.assign(meta, reactions.meta);
14884
15091
  try {
14885
- await this.deliverToInstance(instanceName, {
15092
+ await this.deliverHumanToInstance(instanceName, {
14886
15093
  type: "fleet_inbound",
14887
15094
  content: fullText,
14888
15095
  targetSession: instanceName,
14889
15096
  meta,
14890
- });
15097
+ }, msg.humanOrigin, msg.humanOriginalBody ?? text);
14891
15098
  reactions.consume();
14892
15099
  }
14893
15100
  catch (err) {
@@ -16496,6 +16703,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16496
16703
  clearInterval(this.eventLogPruneTimer);
16497
16704
  this.eventLogPruneTimer = null;
16498
16705
  }
16706
+ this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
16499
16707
  if (this.outboxPruneTimer) {
16500
16708
  clearInterval(this.outboxPruneTimer);
16501
16709
  this.outboxPruneTimer = null;