History

feat: give every Hermes instance a Home thread for proactive delivery

Hermes has a first-class "home channel": the default destination for output nobody asked for at a specific address — a cron result, an agent-initiated `send_message`, a gateway lifecycle notice, a `/handoff`. The T3 plugin opted out, suppressing Hermes' "Type /sethome" nudge without designating anything. A cron job with `deliver=t3` went nowhere, `send_message` to a bare `t3` target errored, and `/handoff t3` hard-failed. Each enrolled instance now gets one Home thread, pinned first in its agent project, fully conversational, undeletable, created automatically. Protocol v3. `connection.hello` gains `role: "gateway" | "delivery"`; `connection.accepted` carries `homeThreadId`. Two new frames: `home.deliver` and `home.deliver.ack`. Designation converges on read, like agent projects — a precondition every handshake runs rather than an event one caller owns, so an instance enrolled before this shipped, or one whose thread creation failed once, heals on the next connect. It lives in the instance's settings blob beside `connectorUrl` and `revoked`; the plugin's `T3_HOME_CHANNEL` is a cache of it, reconciled every handshake. The existence check deliberately ignores archive state. Every other thread read filters `archived_at IS NULL`, which is right for "what should the user see" and wrong for "does this row exist" — an archived Home read as deleted and the self-healing path replaced it, stranding the history in a thread the user had merely parked. A delivery to an archived Home now un-archives it, the same way incoming activity wakes a settled or snoozed thread. Deliveries bypass the turn machinery entirely. One may arrive while the home thread has a live user turn, so it appends as a turnless assistant message carrying `notification` provenance rather than joining the turn — attributing it would fold it away behind that turn's "Worked for …". Everything except a lifecycle notice un-settles the thread and pushes; lifecycle notices land quietly. Idempotency is load-bearing, because the plugin's durable queue retries anything it has not seen acked. T3 dedupes on `deliveryId` against `projection_thread_messages` before dispatching — the decider's own scan sees the in-memory read model, which is built with `messages: []`, so it cannot catch a replay after a restart. The ack is sent only after the write lands. An out-of-process cron run dials in with `role: "delivery"`: authenticated identically, but never registered as the instance's primary connection, so it cannot displace a live gateway socket or answer its pending requests. Plugin side, the proactive branch in `send()` gates on provenance rather than turn absence. The naive rule — "no active turn, so deliver" — would let a notify-stamped cron delivery complete a user's live turn with cron output as its answer. A bare session-key mismatch is not enough either, since `HERMES_SESSION_KEY` resolves empty whenever the context did not propagate; that would tear a genuine reply out of its turn. Bypassing a live turn requires positively identified cron/lifecycle/handoff provenance.

Ben Davis committed 3 months ago

Commit 784fcb71 parent 5bfb90d73 files · +5908 −120Browse files

Unified · Split

73 files
  1. apps/mobile/src/widgets/AgentActivity.tsx +8 −1
  2. apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +5 −0
  3. apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +1 −0
  4. apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +133 −0
  5. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +41 −1
  6. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +1 −0
  7. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +63 −4
  8. apps/server/src/orchestration/Schemas.ts +2 −0
  9. apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +18 −0
  10. apps/server/src/orchestration/agentProjects.test.ts +1 −0
  11. apps/server/src/orchestration/decider.notification.test.ts +237 −0
  12. apps/server/src/orchestration/decider.ts +96 −0
  13. apps/server/src/orchestration/homeThreads.test.ts +408 −0
  14. apps/server/src/orchestration/homeThreads.ts +242 −0
  15. apps/server/src/orchestration/projector.notification.test.ts +149 −0
  16. apps/server/src/orchestration/projector.ts +50 −0
  17. apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +2 −0
  18. apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +74 −0
  19. apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +20 −1
  20. apps/server/src/persistence/Layers/ProjectionThreads.ts +15 −4
  21. apps/server/src/persistence/Migrations.ts +4 −0
  22. apps/server/src/persistence/Migrations/036_ProjectionThreadMessageNotification.test.ts +109 −0
  23. apps/server/src/persistence/Migrations/036_ProjectionThreadMessageNotification.ts +11 −0
  24. apps/server/src/persistence/Migrations/037_ProjectionThreadsLatestNotification.test.ts +80 −0
  25. apps/server/src/persistence/Migrations/037_ProjectionThreadsLatestNotification.ts +22 −0
  26. apps/server/src/persistence/Services/ProjectionThreadMessages.ts +9 −0
  27. apps/server/src/persistence/Services/ProjectionThreads.ts +7 −0
  28. apps/server/src/project/ProjectSetupScriptRunner.test.ts +1 −0
  29. apps/server/src/provider/Drivers/HermesDriver.ts +20 −1
  30. apps/server/src/provider/Layers/HermesGatewayBroker.test.ts +100 −4
  31. apps/server/src/provider/Layers/HermesGatewayBroker.ts +38 −0
  32. apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +1 −0
  33. apps/server/src/provider/Services/HermesGatewayBroker.ts +17 −1
  34. apps/server/src/provider/hermesGatewayHttp.ts +146 −1
  35. apps/server/src/relay/AgentAwarenessRelay.test.ts +24 −0
  36. apps/server/src/relay/AgentAwarenessRelay.ts +5 −0
  37. apps/server/src/serverRuntimeStartup.test.ts +4 −0
  38. apps/server/src/ws.ts +22 −0
  39. apps/web/src/components/Sidebar.logic.test.ts +149 −0
  40. apps/web/src/components/Sidebar.logic.ts +42 −1
  41. apps/web/src/components/Sidebar.tsx +58 −2
  42. apps/web/src/components/agents/AgentPage.logic.test.ts +101 −0
  43. apps/web/src/components/agents/AgentPage.logic.ts +43 −0
  44. apps/web/src/components/agents/AgentPage.tsx +156 −1
  45. apps/web/src/components/chat/MessagesTimeline.tsx +31 −0
  46. apps/web/src/components/chat/notificationBadge.logic.test.ts +63 −0
  47. apps/web/src/components/chat/notificationBadge.logic.ts +59 −0
  48. docs/project/hermes-home-channel-plan.md +165 −13
  49. infra/relay/src/agentActivity/AgentActivityPublisher.ts +2 −0
  50. integrations/hermes-t3-gateway/COMPATIBILITY.md +189 −5
  51. integrations/hermes-t3-gateway/README.md +36 −4
  52. integrations/hermes-t3-gateway/__init__.py +18 −0
  53. integrations/hermes-t3-gateway/adapter.py +242 −4
  54. integrations/hermes-t3-gateway/connection.py +39 −1
  55. integrations/hermes-t3-gateway/home.py +605 −0
  56. integrations/hermes-t3-gateway/plugin.yaml +1 −1
  57. integrations/hermes-t3-gateway/protocol.py +141 −2
  58. integrations/hermes-t3-gateway/tests/test_adapter.py +466 −28
  59. integrations/hermes-t3-gateway/tests/test_connection.py +9 −7
  60. integrations/hermes-t3-gateway/tests/test_home.py +473 −0
  61. integrations/hermes-t3-gateway/tests/test_protocol.py +8 −8
  62. packages/client-runtime/src/state/threadReducer.test.ts +82 −0
  63. packages/client-runtime/src/state/threadReducer.ts +30 −0
  64. packages/client-runtime/src/state/threadSort.test.ts +40 −0
  65. packages/client-runtime/src/state/threadSort.ts +17 −0
  66. packages/contracts/src/hermesGateway.test.ts +113 −20
  67. packages/contracts/src/hermesGateway.ts +98 −3
  68. packages/contracts/src/orchestration.ts +96 −0
  69. packages/contracts/src/relay.ts +2 −0
  70. packages/contracts/src/server.ts +9 −0
  71. packages/contracts/src/settings.ts +13 −0
  72. packages/shared/src/agentAwareness.test.ts +74 −0
  73. packages/shared/src/agentAwareness.ts +52 −2
apps/mobile/src/widgets/AgentActivity.tsx+8−1
@@ -23,6 +23,7 @@ export type AgentActivityPhase = · 22 unmodified lines
2323 | "running"
2424 | "waiting_for_approval"
2525 | "waiting_for_input"
26+ | "notification"
2627 | "completed"
2728 | "failed"
2829 | "stale";
@@ -81,6 +82,8 @@ export function AgentActivity( · 52 unmodified lines
8182 return isLightScheme ? "#d97706" : "#fcd34d"; // amber-600 / amber-300
8283 case "waiting_for_input":
8384 return isLightScheme ? "#4f46e5" : "#a5b4fc"; // indigo-600 / indigo-300
85+ case "notification":
86+ return isLightScheme ? "#7c3aed" : "#c4b5fd"; // violet-600 / violet-300
8487 case "failed":
8588 return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-300
8689 case "completed":
@@ -97,7 +100,9 @@ export function AgentActivity( · 10 unmodified lines
97100 const phasePriority = (phase: AgentActivityPhase): number => {
98101 if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0;
99102 if (phase === "failed") return 1;
100− if (phase === "running" || phase === "starting") return 2;
103+ // A proactive delivery is unread news, so it outranks finished/stale rows
104+ // but never a human who is actually blocked.
105+ if (phase === "running" || phase === "starting" || phase === "notification") return 2;
101106 return 3;
102107 };
103108 const ordered = [...props.activities].sort(
@@ -163,6 +168,8 @@ export function AgentActivity( · 59 unmodified lines
163168 return "exclamationmark.circle.fill";
164169 case "waiting_for_input":
165170 return "questionmark.circle.fill";
171+ case "notification":
172+ return "bell.badge.fill";
166173 case "failed":
167174 return "xmark.octagon.fill";
168175 case "completed":
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+5−0
@@ -107,6 +107,7 @@ describe("CheckpointDiffQuery.layer", () => { · 106 unmodified lines
107107 });
108108 }),
109109 getThreadShellById: () => Effect.succeed(Option.none()),
110+ getThreadArchiveStateById: () => Effect.succeed(Option.none()),
110111 getThreadDetailById: () => Effect.succeed(Option.none()),
111112 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
112113 }),
@@ -201,6 +202,7 @@ describe("CheckpointDiffQuery.layer", () => { · 88 unmodified lines
201202 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
202203 getFullThreadDiffContext: () => Effect.die("unused"),
203204 getThreadShellById: () => Effect.succeed(Option.none()),
205+ getThreadArchiveStateById: () => Effect.succeed(Option.none()),
204206 getThreadDetailById: () => Effect.succeed(Option.none()),
205207 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
206208 }),
@@ -285,6 +287,7 @@ describe("CheckpointDiffQuery.layer", () => { · 78 unmodified lines
285287 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
286288 getFullThreadDiffContext: () => Effect.die("unused"),
287289 getThreadShellById: () => Effect.succeed(Option.none()),
290+ getThreadArchiveStateById: () => Effect.succeed(Option.none()),
288291 getThreadDetailById: () => Effect.succeed(Option.none()),
289292 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
290293 }),
@@ -354,6 +357,7 @@ describe("CheckpointDiffQuery.layer", () => { · 63 unmodified lines
354357 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
355358 getFullThreadDiffContext: () => Effect.die("unused"),
356359 getThreadShellById: () => Effect.succeed(Option.none()),
360+ getThreadArchiveStateById: () => Effect.succeed(Option.none()),
357361 getThreadDetailById: () => Effect.succeed(Option.none()),
358362 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
359363 }),
@@ -408,6 +412,7 @@ describe("CheckpointDiffQuery.layer", () => { · 48 unmodified lines
408412 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
409413 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
410414 getThreadShellById: () => Effect.succeed(Option.none()),
415+ getThreadArchiveStateById: () => Effect.succeed(Option.none()),
411416 getThreadDetailById: () => Effect.succeed(Option.none()),
412417 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
413418 }),
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+1−0
@@ -203,6 +203,7 @@ describe("OrchestrationEngine", () => { · 202 unmodified lines
203203 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
204204 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
205205 getThreadShellById: () => Effect.succeed(Option.none()),
206+ getThreadArchiveStateById: () => Effect.succeed(Option.none()),
206207 getThreadDetailById: () => Effect.succeed(Option.none()),
207208 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
208209 }),
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+133−0
@@ -2368,6 +2368,139 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 2367 unmodified lines
23682368 }),
23692369 );
23702370
2371+ it.effect("projects notification deliveries into the message row and shell summary", () =>
2372+ Effect.gen(function* () {
2373+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
2374+ const eventStore = yield* OrchestrationEventStore;
2375+ const sql = yield* SqlClient.SqlClient;
2376+ const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
2377+ eventStore
2378+ .append(event)
2379+ .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
2380+ const createdAt = "2026-07-01T09:00:00.000Z";
2381+
2382+ yield* appendAndProject({
2383+ type: "project.created",
2384+ eventId: EventId.make("evt-notify-project"),
2385+ aggregateKind: "project",
2386+ aggregateId: ProjectId.make("project-notify"),
2387+ occurredAt: createdAt,
2388+ commandId: CommandId.make("cmd-notify-project"),
2389+ causationEventId: null,
2390+ correlationId: CommandId.make("cmd-notify-project"),
2391+ metadata: {},
2392+ payload: {
2393+ projectId: ProjectId.make("project-notify"),
2394+ title: "Hermes",
2395+ workspaceRoot: "/tmp/project-notify",
2396+ defaultModelSelection: null,
2397+ scripts: [],
2398+ createdAt,
2399+ updatedAt: createdAt,
2400+ },
2401+ });
2402+
2403+ yield* appendAndProject({
2404+ type: "thread.created",
2405+ eventId: EventId.make("evt-notify-thread"),
2406+ aggregateKind: "thread",
2407+ aggregateId: ThreadId.make("thread-notify"),
2408+ occurredAt: createdAt,
2409+ commandId: CommandId.make("cmd-notify-thread"),
2410+ causationEventId: null,
2411+ correlationId: CommandId.make("cmd-notify-thread"),
2412+ metadata: {},
2413+ payload: {
2414+ threadId: ThreadId.make("thread-notify"),
2415+ projectId: ProjectId.make("project-notify"),
2416+ title: "Home",
2417+ modelSelection: {
2418+ instanceId: ProviderInstanceId.make("hermes"),
2419+ model: "hermes",
2420+ },
2421+ runtimeMode: "full-access",
2422+ branch: null,
2423+ worktreePath: null,
2424+ createdAt,
2425+ updatedAt: createdAt,
2426+ },
2427+ });
2428+
2429+ const deliveredAt = "2026-07-01T09:05:00.000Z";
2430+ yield* appendAndProject({
2431+ type: "thread.notification-delivered",
2432+ eventId: EventId.make("evt-notify-delivery"),
2433+ aggregateKind: "thread",
2434+ aggregateId: ThreadId.make("thread-notify"),
2435+ occurredAt: deliveredAt,
2436+ commandId: CommandId.make("cmd-notify-delivery"),
2437+ causationEventId: null,
2438+ correlationId: CommandId.make("cmd-notify-delivery"),
2439+ metadata: {},
2440+ payload: {
2441+ threadId: ThreadId.make("thread-notify"),
2442+ messageId: MessageId.make("message-notify"),
2443+ text: "Digest ready.",
2444+ notification: {
2445+ kind: "cron",
2446+ label: "Cron: daily-digest",
2447+ deliveryId: "delivery-1",
2448+ },
2449+ createdAt: deliveredAt,
2450+ updatedAt: deliveredAt,
2451+ },
2452+ });
2453+
2454+ const messageRows = yield* sql<{
2455+ readonly messageId: string;
2456+ readonly text: string;
2457+ readonly turnId: string | null;
2458+ readonly role: string;
2459+ readonly isStreaming: number;
2460+ readonly notification: string | null;
2461+ }>`
2462+ SELECT
2463+ message_id AS "messageId",
2464+ text,
2465+ turn_id AS "turnId",
2466+ role,
2467+ is_streaming AS "isStreaming",
2468+ notification_json AS "notification"
2469+ FROM projection_thread_messages
2470+ WHERE thread_id = 'thread-notify'
2471+ `;
2472+ assert.equal(messageRows.length, 1);
2473+ assert.equal(messageRows[0]?.messageId, "message-notify");
2474+ assert.equal(messageRows[0]?.turnId, null);
2475+ assert.equal(messageRows[0]?.role, "assistant");
2476+ assert.equal(messageRows[0]?.isStreaming, 0);
2477+ assert.deepEqual(
2478+ // @effect-diagnostics-next-line preferSchemaOverJson:off
2479+ JSON.parse(messageRows[0]?.notification ?? "null"),
2480+ { kind: "cron", label: "Cron: daily-digest", deliveryId: "delivery-1" },
2481+ );
2482+
2483+ // The shell summary is what the sidebar and the push relay read; the
2484+ // provenance is invisible to them unless it is flattened here.
2485+ const threadRows = yield* sql<{
2486+ readonly updatedAt: string;
2487+ readonly latestNotification: string | null;
2488+ }>`
2489+ SELECT
2490+ updated_at AS "updatedAt",
2491+ latest_notification_json AS "latestNotification"
2492+ FROM projection_threads
2493+ WHERE thread_id = 'thread-notify'
2494+ `;
2495+ assert.equal(threadRows[0]?.updatedAt, deliveredAt);
2496+ assert.deepEqual(
2497+ // @effect-diagnostics-next-line preferSchemaOverJson:off
2498+ JSON.parse(threadRows[0]?.latestNotification ?? "null"),
2499+ { kind: "cron", label: "Cron: daily-digest", deliveredAt },
2500+ );
2501+ }),
2502+ );
2503+
23712504 it.effect("does not fallback-retain messages whose turnId is removed by revert", () =>
23722505 Effect.gen(function* () {
23732506 const projectionPipeline = yield* OrchestrationProjectionPipeline;
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+41−1
@@ -34,7 +34,10 @@ import { · 33 unmodified lines
3434 type ProjectionTurn,
3535 ProjectionTurnRepository,
3636 } from "../../persistence/Services/ProjectionTurns.ts";
37−import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts";
37+import {
38+ type ProjectionThread,
39+ ProjectionThreadRepository,
40+} from "../../persistence/Services/ProjectionThreads.ts";
3841 import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts";
3942 import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts";
4043 import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts";
@@ -564,6 +567,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 523 unmodified lines
564567 ]);
565568
566569 let latestUserMessageAt: string | null = null;
570+ // Newest proactive delivery, flattened for shell readers that never load
571+ // messages (sidebar rows, agent awareness). Ties break on message id so
572+ // two deliveries stamped in the same millisecond resolve deterministically.
573+ let latestNotification: ProjectionThread["latestNotification"] = null;
574+ let latestNotificationKey: string | null = null;
567575 for (const message of messages) {
568576 if (
569577 message.role === "user" &&
@@ -571,6 +579,17 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 1 unmodified line
571579 ) {
572580 latestUserMessageAt = message.createdAt;
573581 }
582+ if (message.notification !== undefined) {
583+ const key = `${message.createdAt}${message.messageId}`;
584+ if (latestNotificationKey === null || key > latestNotificationKey) {
585+ latestNotificationKey = key;
586+ latestNotification = {
587+ kind: message.notification.kind,
588+ label: message.notification.label,
589+ deliveredAt: message.createdAt,
590+ };
591+ }
592+ }
574593 }
575594
576595 const pendingApprovalCount = pendingApprovals.filter(
@@ -588,6 +607,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 11 unmodified lines
588607 pendingApprovalCount,
589608 pendingUserInputCount,
590609 hasActionableProposedPlan: hasActionableProposedPlan ? 1 : 0,
610+ latestNotification,
591611 });
592612 });
593613
@@ -617,6 +637,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 23 unmodified lines
617637 pendingApprovalCount: 0,
618638 pendingUserInputCount: 0,
619639 hasActionableProposedPlan: 0,
640+ latestNotification: null,
620641 deletedAt: null,
621642 });
622643 return;
@@ -784,6 +805,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 161 unmodified lines
784805 }
785806
786807 case "thread.message-sent":
808+ case "thread.notification-delivered":
787809 case "thread.proposed-plan-upserted":
788810 case "thread.activity-appended":
789811 case "thread.approval-response-requested":
@@ -917,6 +939,24 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 127 unmodified lines
917939 return;
918940 }
919941
942+ case "thread.notification-delivered": {
943+ // No merge accounting: a delivery is written whole, never streamed,
944+ // and a replay re-emits the identical payload (the decider rebuilds
945+ // it from the persisted row), so the upsert is idempotent.
946+ yield* projectionThreadMessageRepository.upsert({
947+ messageId: event.payload.messageId,
948+ threadId: event.payload.threadId,
949+ turnId: null,
950+ role: "assistant",
951+ text: event.payload.text,
952+ notification: event.payload.notification,
953+ isStreaming: false,
954+ createdAt: event.payload.createdAt,
955+ updatedAt: event.payload.updatedAt,
956+ });
957+ return;
958+ }
959+
920960 case "thread.reverted": {
921961 const existingRows = yield* projectionThreadMessageRepository.listByThreadId({
922962 threadId: event.payload.threadId,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1−0
@@ -441,6 +441,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 440 unmodified lines
441441 hasPendingApprovals: true,
442442 hasPendingUserInput: false,
443443 hasActionableProposedPlan: false,
444+ latestNotification: null,
444445 },
445446 ]);
446447
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+63−4
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
55 MessageId,
66 NonNegativeInt,
77 OrchestrationCheckpointFile,
8+ OrchestrationMessageNotification,
89 OrchestrationProposedPlanId,
910 OrchestrationReadModel,
1011 OrchestrationShellSnapshot,
@@ -22,6 +23,7 @@ import { · 11 unmodified lines
2223 type OrchestrationThreadActivity,
2324 type OrchestrationThreadShell,
2425 ModelSelection,
26+ OrchestrationThreadNotificationSummary,
2527 ProjectId,
2628 ProviderInstanceId,
2729 ThreadId,
@@ -73,12 +75,19 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( · 45 unmodified lines
7375 Struct.assign({
7476 isStreaming: Schema.Number,
7577 attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))),
78+ notification: Schema.NullOr(Schema.fromJsonString(OrchestrationMessageNotification)),
7679 }),
7780 );
7881 const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;
7982 const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
8083 Struct.assign({
8184 modelSelection: Schema.fromJsonString(ModelSelection),
85+ // Stored as a JSON string, like `modelSelection`. Every column the row
86+ // schema does not decode here arrives as raw text and fails the field's
87+ // object schema — which takes the whole thread read down with it.
88+ latestNotification: Schema.NullOr(
89+ Schema.fromJsonString(OrchestrationThreadNotificationSummary),
90+ ),
8291 }),
8392 );
8493 const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
@@ -348,6 +357,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 263 unmodified lines
348357 pending_approval_count AS "pendingApprovalCount",
349358 pending_user_input_count AS "pendingUserInputCount",
350359 has_actionable_proposed_plan AS "hasActionableProposedPlan",
360+ latest_notification_json AS "latestNotification",
351361 deleted_at AS "deletedAt"
352362 FROM projection_threads
353363 ORDER BY created_at ASC, thread_id ASC
@@ -380,6 +390,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 26 unmodified lines
380390 pending_approval_count AS "pendingApprovalCount",
381391 pending_user_input_count AS "pendingUserInputCount",
382392 has_actionable_proposed_plan AS "hasActionableProposedPlan",
393+ latest_notification_json AS "latestNotification",
383394 deleted_at AS "deletedAt"
384395 FROM projection_threads
385396 WHERE deleted_at IS NULL
@@ -414,6 +425,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 28 unmodified lines
414425 pending_approval_count AS "pendingApprovalCount",
415426 pending_user_input_count AS "pendingUserInputCount",
416427 has_actionable_proposed_plan AS "hasActionableProposedPlan",
428+ latest_notification_json AS "latestNotification",
417429 deleted_at AS "deletedAt"
418430 FROM projection_threads
419431 WHERE deleted_at IS NULL
@@ -434,6 +446,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 14 unmodified lines
434446 role,
435447 text,
436448 attachments_json AS "attachments",
449+ notification_json AS "notification",
437450 is_streaming AS "isStreaming",
438451 created_at AS "createdAt",
439452 updated_at AS "updatedAt"
@@ -786,6 +799,26 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 346 unmodified lines
786799 `,
787800 });
788801
802+ // Existence, not visibility: deliberately no `archived_at IS NULL`. Selects
803+ // only the two columns it needs so it cannot be broken by an undecoded
804+ // JSON column elsewhere in the row.
805+ const getThreadArchiveStateRowById = SqlSchema.findOneOption({
806+ Request: ThreadIdLookupInput,
807+ Result: Schema.Struct({
808+ archivedAt: Schema.NullOr(Schema.String),
809+ deletedAt: Schema.NullOr(Schema.String),
810+ }),
811+ execute: ({ threadId }) =>
812+ sql`
813+ SELECT
814+ archived_at AS "archivedAt",
815+ deleted_at AS "deletedAt"
816+ FROM projection_threads
817+ WHERE thread_id = ${threadId}
818+ LIMIT 1
819+ `,
820+ });
821+
789822 const getActiveThreadRowById = SqlSchema.findOneOption({
790823 Request: ThreadIdLookupInput,
791824 Result: ProjectionThreadDbRowSchema,
@@ -812,6 +845,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 20 unmodified lines
812845 pending_approval_count AS "pendingApprovalCount",
813846 pending_user_input_count AS "pendingUserInputCount",
814847 has_actionable_proposed_plan AS "hasActionableProposedPlan",
848+ latest_notification_json AS "latestNotification",
815849 deleted_at AS "deletedAt"
816850 FROM projection_threads
817851 WHERE thread_id = ${threadId}
@@ -833,6 +867,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 15 unmodified lines
833867 role,
834868 text,
835869 attachments_json AS "attachments",
870+ notification_json AS "notification",
836871 is_streaming AS "isStreaming",
837872 created_at AS "createdAt",
838873 updated_at AS "updatedAt"
@@ -1104,6 +1139,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 265 unmodified lines
11041139 role: row.role,
11051140 text: row.text,
11061141 ...(row.attachments !== null ? { attachments: row.attachments } : {}),
1142+ ...(row.notification !== null ? { notification: row.notification } : {}),
11071143 turnId: row.turnId,
11081144 streaming: row.isStreaming === 1,
11091145 createdAt: row.createdAt,
@@ -1586,6 +1622,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 476 unmodified lines
15861622 hasPendingApprovals: row.pendingApprovalCount > 0,
15871623 hasPendingUserInput: row.pendingUserInputCount > 0,
15881624 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
1625+ latestNotification: row.latestNotification,
15891626 } satisfies OrchestrationThreadShell)
15901627 : Result.failVoid,
15911628 ),
@@ -1724,6 +1761,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 132 unmodified lines
17241761 hasPendingApprovals: row.pendingApprovalCount > 0,
17251762 hasPendingUserInput: row.pendingUserInputCount > 0,
17261763 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
1764+ latestNotification: row.latestNotification,
17271765 }),
17281766 ),
17291767 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
@@ -1946,6 +1984,26 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 216 unmodified lines
19461984 });
19471985 });
19481986
1987+ const getThreadArchiveStateById: ProjectionSnapshotQueryShape["getThreadArchiveStateById"] = (
1988+ threadId,
1989+ ) =>
1990+ getThreadArchiveStateRowById({ threadId }).pipe(
1991+ Effect.mapError(
1992+ toPersistenceSqlOrDecodeError(
1993+ "ProjectionSnapshotQuery.getThreadArchiveStateById:query",
1994+ "ProjectionSnapshotQuery.getThreadArchiveStateById:decodeRow",
1995+ ),
1996+ ),
1997+ Effect.map(
1998+ Option.flatMap((row) =>
1999+ // A soft-deleted row is gone; an archived one is merely parked.
2000+ row.deletedAt !== null
2001+ ? Option.none<{ readonly archivedAt: string | null }>()
2002+ : Option.some({ archivedAt: row.archivedAt }),
2003+ ),
2004+ ),
2005+ );
2006+
19492007 const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) =>
19502008 Effect.gen(function* () {
19512009 const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([
@@ -2001,6 +2059,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 49 unmodified lines
20012059 hasPendingApprovals: threadRow.value.pendingApprovalCount > 0,
20022060 hasPendingUserInput: threadRow.value.pendingUserInputCount > 0,
20032061 hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0,
2062+ latestNotification: threadRow.value.latestNotification,
20042063 } satisfies OrchestrationThreadShell);
20052064 });
20062065
@@ -2096,7 +2155,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 89 unmodified lines
20962155 snoozedAt: threadRow.value.snoozedAt,
20972156 deletedAt: null,
20982157 messages: messageRows.map((row) => {
2099− const message = {
2158+ const message: OrchestrationMessage = {
21002159 id: row.messageId,
21012160 role: row.role,
21022161 text: row.text,
@@ -2104,10 +2163,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 1 unmodified line
21042163 streaming: row.isStreaming === 1,
21052164 createdAt: row.createdAt,
21062165 updatedAt: row.updatedAt,
2166+ ...(row.attachments !== null ? { attachments: row.attachments } : {}),
2167+ ...(row.notification !== null ? { notification: row.notification } : {}),
21072168 };
2108− if (row.attachments !== null) {
2109− return Object.assign(message, { attachments: row.attachments });
2110− }
21112169 return message;
21122170 }),
21132171 proposedPlans: proposedPlanRows.map(mapProposedPlanRow),
@@ -2190,6 +2248,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 76 unmodified lines
21902248 getThreadCheckpointContext,
21912249 getFullThreadDiffContext,
21922250 getThreadShellById,
2251+ getThreadArchiveStateById,
21932252 getThreadDetailById,
21942253 getThreadDetailSnapshot,
21952254 } satisfies ProjectionSnapshotQueryShape;
apps/server/src/orchestration/Schemas.ts+2−0
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
1919 ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema,
2020 ThreadRevertedPayload as ContractsThreadRevertedPayloadSchema,
2121 ThreadActivityAppendedPayload as ContractsThreadActivityAppendedPayloadSchema,
22+ ThreadNotificationDeliveredPayload as ContractsThreadNotificationDeliveredPayloadSchema,
2223 ThreadTurnStartRequestedPayload as ContractsThreadTurnStartRequestedPayloadSchema,
2324 ThreadTurnInterruptRequestedPayload as ContractsThreadTurnInterruptRequestedPayloadSchema,
2425 ThreadApprovalResponseRequestedPayload as ContractsThreadApprovalResponseRequestedPayloadSchema,
@@ -49,6 +50,7 @@ export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema; · 24 unmodified lines
4950 export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema;
5051 export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema;
5152 export const ThreadActivityAppendedPayload = ContractsThreadActivityAppendedPayloadSchema;
53+export const ThreadNotificationDeliveredPayload = ContractsThreadNotificationDeliveredPayloadSchema;
5254
5355 export const ThreadTurnStartRequestedPayload = ContractsThreadTurnStartRequestedPayloadSchema;
5456 export const ThreadTurnInterruptRequestedPayload =
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+18−0
@@ -165,6 +165,24 @@ export interface ProjectionSnapshotQueryShape { · 164 unmodified lines
165165 threadId: ThreadId,
166166 ) => Effect.Effect<Option.Option<OrchestrationThreadShell>, ProjectionRepositoryError>;
167167
168+ /**
169+ * Answer "does this thread row exist", independent of archive state.
170+ *
171+ * Every other thread read here filters `archived_at IS NULL`, which is right
172+ * for "what should the user see" and wrong for existence: an archived thread
173+ * is parked, not gone. Callers holding a durable reference to a specific
174+ * thread — the Hermes home-thread designation — need this distinction, or
175+ * archiving reads as deletion and they replace a thread the user still has.
176+ *
177+ * A soft-deleted row still reports absent.
178+ */
179+ readonly getThreadArchiveStateById: (
180+ threadId: ThreadId,
181+ ) => Effect.Effect<
182+ Option.Option<{ readonly archivedAt: string | null }>,
183+ ProjectionRepositoryError
184+ >;
185+
168186 /**
169187 * Read a single active thread detail snapshot by id.
170188 */
apps/server/src/orchestration/agentProjects.test.ts+1−0
@@ -68,6 +68,7 @@ const makeQueryLayer = ( · 67 unmodified lines
6868 getThreadCheckpointContext: () => Effect.die("unused"),
6969 getFullThreadDiffContext: () => Effect.die("unused"),
7070 getThreadShellById: () => Effect.die("unused"),
71+ getThreadArchiveStateById: () => Effect.die("unused"),
7172 getThreadDetailById: () => Effect.die("unused"),
7273 getThreadDetailSnapshot: () => Effect.die("unused"),
7374 });
apps/server/src/orchestration/decider.notification.test.ts+237−0
@@ -0,0 +1,237 @@
1+import {
2+ CommandId,
3+ MessageId,
4+ ProjectId,
5+ ProviderInstanceId,
6+ ThreadId,
7+ type OrchestrationReadModel,
8+ type OrchestrationThread,
9+} from "@t3tools/contracts";
10+import * as NodeServices from "@effect/platform-node/NodeServices";
11+import { expect, it } from "@effect/vitest";
12+import * as Effect from "effect/Effect";
13+
14+import { decideOrchestrationCommand } from "./decider.ts";
15+
16+const NOW = "2026-07-01T00:00:00.000Z";
17+const DELIVERED_AT = "2026-06-30T09:00:00.000Z";
18+const SETTLED_AT = "2026-06-29T00:00:00.000Z";
19+const SNOOZED_UNTIL = "2026-07-02T00:00:00.000Z";
20+
21+function makeReadModel(input: {
22+ readonly settledOverride?: OrchestrationThread["settledOverride"];
23+ readonly snoozedUntil?: string | null;
24+ readonly messages?: OrchestrationThread["messages"];
25+}): OrchestrationReadModel {
26+ const settledOverride = input.settledOverride ?? null;
27+ return {
28+ snapshotSequence: 0,
29+ projects: [],
30+ threads: [
31+ {
32+ id: ThreadId.make("thread-1"),
33+ projectId: ProjectId.make("project-1"),
34+ title: "Home",
35+ modelSelection: { instanceId: ProviderInstanceId.make("hermes"), model: "hermes" },
36+ runtimeMode: "full-access",
37+ interactionMode: "default",
38+ branch: null,
39+ worktreePath: null,
40+ latestTurn: null,
41+ createdAt: NOW,
42+ updatedAt: NOW,
43+ archivedAt: null,
44+ settledOverride,
45+ settledAt: settledOverride === "settled" ? SETTLED_AT : null,
46+ snoozedUntil: input.snoozedUntil ?? null,
47+ snoozedAt: input.snoozedUntil != null ? SETTLED_AT : null,
48+ deletedAt: null,
49+ messages: input.messages ?? [],
50+ proposedPlans: [],
51+ activities: [],
52+ checkpoints: [],
53+ session: null,
54+ },
55+ ],
56+ updatedAt: NOW,
57+ };
58+}
59+
60+function deliverCommand(overrides: {
61+ readonly commandId: string;
62+ readonly kind?: "cron" | "message" | "lifecycle" | "handoff" | "other";
63+ readonly deliveryId?: string;
64+ readonly messageId?: string;
65+ readonly text?: string;
66+}) {
67+ return {
68+ type: "thread.notification.deliver",
69+ commandId: CommandId.make(overrides.commandId),
70+ threadId: ThreadId.make("thread-1"),
71+ messageId: MessageId.make(overrides.messageId ?? "message-1"),
72+ deliveryId: overrides.deliveryId ?? "delivery-1",
73+ kind: overrides.kind ?? "cron",
74+ label: "Cron: daily-digest",
75+ text: overrides.text ?? "Digest ready.",
76+ createdAt: NOW,
77+ } as const;
78+}
79+
80+const deliveredMessage: OrchestrationThread["messages"][number] = {
81+ id: MessageId.make("message-1"),
82+ role: "assistant",
83+ text: "Digest ready.",
84+ turnId: null,
85+ streaming: false,
86+ notification: {
87+ kind: "cron",
88+ label: "Cron: daily-digest",
89+ deliveryId: "delivery-1",
90+ },
91+ createdAt: DELIVERED_AT,
92+ updatedAt: DELIVERED_AT,
93+};
94+
95+it.layer(NodeServices.layer)("notification delivery decider", (it) => {
96+ it.effect("appends a turnless notification message and un-settles the thread", () =>
97+ Effect.gen(function* () {
98+ const result = yield* decideOrchestrationCommand({
99+ command: deliverCommand({ commandId: "cmd-deliver" }),
100+ readModel: makeReadModel({ settledOverride: "settled" }),
101+ });
102+ const events = Array.isArray(result) ? result : [result];
103+ expect(events.map((event) => event.type)).toEqual([
104+ "thread.unsettled",
105+ "thread.notification-delivered",
106+ ]);
107+
108+ const unsettled = events[0];
109+ if (unsettled?.type === "thread.unsettled") {
110+ expect(unsettled.payload.reason).toBe("activity");
111+ }
112+
113+ const delivered = events[1];
114+ if (delivered?.type === "thread.notification-delivered") {
115+ expect(delivered.payload.messageId).toBe("message-1");
116+ expect(delivered.payload.text).toBe("Digest ready.");
117+ expect(delivered.payload.notification).toEqual({
118+ kind: "cron",
119+ label: "Cron: daily-digest",
120+ deliveryId: "delivery-1",
121+ });
122+ // Nothing about a delivery belongs to a turn; attributing it to the
123+ // thread's live turn would fold it behind that turn's summary.
124+ expect("turnId" in delivered.payload).toBe(false);
125+ }
126+ }),
127+ );
128+
129+ it.effect("un-snoozes as well as un-settles, and does neither when neither applies", () =>
130+ Effect.gen(function* () {
131+ const both = yield* decideOrchestrationCommand({
132+ command: deliverCommand({ commandId: "cmd-deliver-both" }),
133+ readModel: makeReadModel({ settledOverride: "settled", snoozedUntil: SNOOZED_UNTIL }),
134+ });
135+ expect((Array.isArray(both) ? both : [both]).map((event) => event.type)).toEqual([
136+ "thread.unsettled",
137+ "thread.unsnoozed",
138+ "thread.notification-delivered",
139+ ]);
140+
141+ const neither = yield* decideOrchestrationCommand({
142+ command: deliverCommand({ commandId: "cmd-deliver-neither" }),
143+ readModel: makeReadModel({}),
144+ });
145+ expect((Array.isArray(neither) ? neither : [neither]).map((event) => event.type)).toEqual([
146+ "thread.notification-delivered",
147+ ]);
148+ }),
149+ );
150+
151+ it.effect("appends lifecycle notices quietly, leaving settled and snoozed state alone", () =>
152+ Effect.gen(function* () {
153+ const result = yield* decideOrchestrationCommand({
154+ command: deliverCommand({ commandId: "cmd-lifecycle", kind: "lifecycle" }),
155+ readModel: makeReadModel({ settledOverride: "settled", snoozedUntil: SNOOZED_UNTIL }),
156+ });
157+ const events = Array.isArray(result) ? result : [result];
158+ // Unread indicator only: a restart notice must not drag a thread the
159+ // user has parked back into the inbox.
160+ expect(events.map((event) => event.type)).toEqual(["thread.notification-delivered"]);
161+ }),
162+ );
163+
164+ it.effect("dedupes a replayed deliveryId without appending or waking the thread", () =>
165+ Effect.gen(function* () {
166+ const result = yield* decideOrchestrationCommand({
167+ // The plugin's retry queue resends with a fresh command id, a new
168+ // message id, and possibly edited text — only deliveryId is stable.
169+ command: deliverCommand({
170+ commandId: "cmd-deliver-retry",
171+ messageId: "message-retry",
172+ text: "Digest ready (retry).",
173+ }),
174+ readModel: makeReadModel({
175+ settledOverride: "settled",
176+ snoozedUntil: SNOOZED_UNTIL,
177+ messages: [deliveredMessage],
178+ }),
179+ });
180+ const events = Array.isArray(result) ? result : [result];
181+
182+ // No un-settle/un-snooze: a replay is not new activity.
183+ expect(events.map((event) => event.type)).toEqual(["thread.notification-delivered"]);
184+
185+ const delivered = events[0];
186+ if (delivered?.type === "thread.notification-delivered") {
187+ // Re-emitted from the persisted row, so reducing it a second time is
188+ // a byte-identical no-op rather than a duplicate or an overwrite.
189+ expect(delivered.payload.messageId).toBe("message-1");
190+ expect(delivered.payload.text).toBe("Digest ready.");
191+ expect(delivered.payload.createdAt).toBe(DELIVERED_AT);
192+ expect(delivered.payload.updatedAt).toBe(DELIVERED_AT);
193+ expect(delivered.occurredAt).toBe(DELIVERED_AT);
194+ }
195+ }),
196+ );
197+
198+ it.effect("treats a different deliveryId on the same thread as a new delivery", () =>
199+ Effect.gen(function* () {
200+ const result = yield* decideOrchestrationCommand({
201+ command: deliverCommand({
202+ commandId: "cmd-deliver-second",
203+ deliveryId: "delivery-2",
204+ messageId: "message-2",
205+ text: "Second digest.",
206+ }),
207+ readModel: makeReadModel({
208+ settledOverride: "settled",
209+ messages: [deliveredMessage],
210+ }),
211+ });
212+ const events = Array.isArray(result) ? result : [result];
213+ expect(events.map((event) => event.type)).toEqual([
214+ "thread.unsettled",
215+ "thread.notification-delivered",
216+ ]);
217+ const delivered = events[1];
218+ if (delivered?.type === "thread.notification-delivered") {
219+ expect(delivered.payload.messageId).toBe("message-2");
220+ expect(delivered.payload.notification.deliveryId).toBe("delivery-2");
221+ }
222+ }),
223+ );
224+
225+ it.effect("rejects a delivery for a thread that does not exist", () =>
226+ Effect.gen(function* () {
227+ const error = yield* decideOrchestrationCommand({
228+ command: {
229+ ...deliverCommand({ commandId: "cmd-deliver-missing" }),
230+ threadId: ThreadId.make("thread-missing"),
231+ },
232+ readModel: makeReadModel({}),
233+ }).pipe(Effect.flip);
234+ expect(error._tag).toBe("OrchestrationCommandInvariantError");
235+ }),
236+ );
237+});
apps/server/src/orchestration/decider.ts+96−0
@@ -1197,6 +1197,102 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 1196 unmodified lines
11971197 return [unsettledEvent, activityAppendedEvent];
11981198 }
11991199
1200+ case "thread.notification.deliver": {
1201+ const thread = yield* requireThread({
1202+ readModel,
1203+ command,
1204+ threadId: command.threadId,
1205+ });
1206+ // Dedupe on deliveryId: the plugin's durable queue retries anything it
1207+ // has not seen acked, so the same delivery legitimately arrives more
1208+ // than once. Idempotency is by re-emission (see thread.settle) because
1209+ // the engine rejects a command that produces no events — but unlike
1210+ // settle, the payload must be rebuilt from the ALREADY-WRITTEN message
1211+ // rather than from the command, so reducing it a second time lands on
1212+ // byte-identical state instead of rewriting the row from a retry whose
1213+ // messageId or text may differ.
1214+ //
1215+ // NOT SUFFICIENT ON ITS OWN. This scans the in-memory read model, which
1216+ // `getCommandReadModel` builds with `messages: []` on every thread — it
1217+ // deliberately does not hydrate message bodies at boot. So this catches
1218+ // only replays of deliveries appended since the process started, and
1219+ // sees nothing after a restart. The delivery path re-checks against
1220+ // `projection_thread_messages` before dispatching
1221+ // (`hermesGatewayHttp.ts`), and that read is what actually holds across
1222+ // a restart. Keep both: this one keeps the aggregate self-consistent for
1223+ // any future caller, the other one is load-bearing.
1224+ const duplicate = thread.messages.find(
1225+ (message) => message.notification?.deliveryId === command.deliveryId,
1226+ );
1227+ const notificationEvent: Omit<OrchestrationEvent, "sequence"> = {
1228+ ...(yield* withEventBase({
1229+ aggregateKind: "thread",
1230+ aggregateId: command.threadId,
1231+ occurredAt: duplicate?.createdAt ?? command.createdAt,
1232+ commandId: command.commandId,
1233+ })),
1234+ type: "thread.notification-delivered",
1235+ payload: {
1236+ threadId: command.threadId,
1237+ messageId: duplicate?.id ?? command.messageId,
1238+ text: duplicate?.text ?? command.text,
1239+ notification: duplicate?.notification ?? {
1240+ kind: command.kind,
1241+ label: command.label,
1242+ deliveryId: command.deliveryId,
1243+ },
1244+ createdAt: duplicate?.createdAt ?? command.createdAt,
1245+ updatedAt: duplicate?.updatedAt ?? command.createdAt,
1246+ },
1247+ };
1248+ // A replay is not new activity: it must never wake a thread the user
1249+ // has since settled or snoozed.
1250+ if (duplicate !== undefined) {
1251+ return notificationEvent;
1252+ }
1253+ // A lifecycle notice ("agent restarted") is bookkeeping, not a claim on
1254+ // the user's attention: it appends quietly and leaves a settled or
1255+ // snoozed thread where it is. Every other kind is the agent raising its
1256+ // hand and resets the lifecycle exactly like a user message does.
1257+ if (command.kind === "lifecycle") {
1258+ return notificationEvent;
1259+ }
1260+ const lifecycleResetEvents: Array<Omit<OrchestrationEvent, "sequence">> = [];
1261+ if (thread.settledOverride !== null) {
1262+ lifecycleResetEvents.push({
1263+ ...(yield* withEventBase({
1264+ aggregateKind: "thread",
1265+ aggregateId: command.threadId,
1266+ occurredAt: command.createdAt,
1267+ commandId: command.commandId,
1268+ })),
1269+ type: "thread.unsettled",
1270+ payload: {
1271+ threadId: command.threadId,
1272+ reason: "activity",
1273+ updatedAt: command.createdAt,
1274+ },
1275+ });
1276+ }
1277+ if (thread.snoozedUntil != null) {
1278+ lifecycleResetEvents.push({
1279+ ...(yield* withEventBase({
1280+ aggregateKind: "thread",
1281+ aggregateId: command.threadId,
1282+ occurredAt: command.createdAt,
1283+ commandId: command.commandId,
1284+ })),
1285+ type: "thread.unsnoozed",
1286+ payload: {
1287+ threadId: command.threadId,
1288+ reason: "activity",
1289+ updatedAt: command.createdAt,
1290+ },
1291+ });
1292+ }
1293+ return [...lifecycleResetEvents, notificationEvent];
1294+ }
1295+
12001296 default: {
12011297 command satisfies never;
12021298 const fallback = command as never as { type: string };
apps/server/src/orchestration/homeThreads.test.ts+408−0
@@ -0,0 +1,408 @@
1+import { assert, it } from "@effect/vitest";
2+import {
3+ DEFAULT_HERMES_MODEL,
4+ HERMES_DRIVER_KIND,
5+ ProjectId,
6+ ProviderInstanceId,
7+ ThreadId,
8+ type OrchestrationCommand,
9+ type OrchestrationProject,
10+ type OrchestrationThreadShell,
11+} from "@t3tools/contracts";
12+import * as NodeServices from "@effect/platform-node/NodeServices";
13+import * as Effect from "effect/Effect";
14+import * as Layer from "effect/Layer";
15+import * as Option from "effect/Option";
16+import * as Ref from "effect/Ref";
17+import * as Stream from "effect/Stream";
18+
19+import { ServerConfig } from "../config.ts";
20+import * as ServerSettings from "../serverSettings.ts";
21+import { OrchestrationCommandInvariantError } from "./Errors.ts";
22+import {
23+ HOME_THREAD_TITLE,
24+ getDesignatedHomeThreadId,
25+ getOrCreateHomeThread,
26+ isHomeThread,
27+ readHomeThreadId,
28+} from "./homeThreads.ts";
29+import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
30+import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
31+
32+const INSTANCE_ID = ProviderInstanceId.make("hermes-siva-local-abc123");
33+const OTHER_INSTANCE_ID = ProviderInstanceId.make("hermes-other-def456");
34+const HOME_THREAD_ID = ThreadId.make("thread-home-1");
35+const BASE_DIR = "/tmp/t3-home-threads-test";
36+
37+const agentProject: OrchestrationProject = {
38+ id: ProjectId.make("agent-project-1"),
39+ title: "Hermes Siva Local",
40+ workspaceRoot: `${BASE_DIR}/agents/${INSTANCE_ID}`,
41+ repositoryIdentity: null,
42+ defaultModelSelection: null,
43+ scripts: [],
44+ agentInstanceId: INSTANCE_ID,
45+ createdAt: "2026-01-01T00:00:00.000Z",
46+ updatedAt: "2026-01-01T00:00:00.000Z",
47+ deletedAt: null,
48+};
49+
50+const threadShell = (id: ThreadId): OrchestrationThreadShell =>
51+ ({
52+ id,
53+ projectId: agentProject.id,
54+ title: HOME_THREAD_TITLE,
55+ modelSelection: { instanceId: INSTANCE_ID, model: DEFAULT_HERMES_MODEL },
56+ runtimeMode: "full-access",
57+ interactionMode: "default",
58+ branch: null,
59+ worktreePath: null,
60+ latestTurn: null,
61+ createdAt: "2026-01-01T00:00:00.000Z",
62+ updatedAt: "2026-01-01T00:00:00.000Z",
63+ archivedAt: null,
64+ settledOverride: null,
65+ settledAt: null,
66+ session: null,
67+ latestUserMessageAt: null,
68+ hasPendingApprovals: false,
69+ hasPendingUserInput: false,
70+ hasActionableProposedPlan: false,
71+ }) as OrchestrationThreadShell;
72+
73+const hermesInstance = (config: Record<string, unknown>) => ({
74+ driver: HERMES_DRIVER_KIND,
75+ displayName: "Hermes Siva Local",
76+ enabled: true,
77+ config,
78+});
79+
80+/**
81+ * Query stub answering thread reads from a mutable queue, so a test can model
82+ * "designated but deleted" — the self-healing path this module exists for.
83+ */
84+const makeQueryLayer = (
85+ threads: Ref.Ref<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>,
86+ archiveStates?: ReadonlyArray<Option.Option<{ readonly archivedAt: string | null }>>,
87+) =>
88+ Layer.succeed(ProjectionSnapshotQuery, {
89+ getCommandReadModel: () => Effect.die("unused"),
90+ getSnapshot: () => Effect.die("unused"),
91+ getShellSnapshot: () => Effect.die("unused"),
92+ getArchivedShellSnapshot: () => Effect.die("unused"),
93+ getSnapshotSequence: () => Effect.die("unused"),
94+ getCounts: () => Effect.die("unused"),
95+ getActiveProjectByWorkspaceRoot: () => Effect.die("unused"),
96+ getActiveAgentProject: () => Effect.succeed(Option.some(agentProject)),
97+ getProjectShellById: () => Effect.die("unused"),
98+ getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
99+ getThreadCheckpointContext: () => Effect.die("unused"),
100+ getFullThreadDiffContext: () => Effect.die("unused"),
101+ getThreadArchiveStateById: () =>
102+ Effect.gen(function* () {
103+ if (archiveStates?.[0] !== undefined) return archiveStates[0];
104+ // Default: mirror the shell queue, since a thread visible to the shell
105+ // query is also present. Tests about archiving pass this explicitly.
106+ const queue = yield* Ref.get(threads);
107+ return Option.map(queue[0] ?? Option.none(), () => ({ archivedAt: null }));
108+ }),
109+ getThreadShellById: () =>
110+ Effect.gen(function* () {
111+ const queue = yield* Ref.get(threads);
112+ const [head, ...rest] = queue;
113+ if (head === undefined) return Option.none<OrchestrationThreadShell>();
114+ yield* Ref.set(threads, rest.length > 0 ? rest : [head]);
115+ return head;
116+ }),
117+ getThreadDetailById: () => Effect.die("unused"),
118+ getThreadDetailSnapshot: () => Effect.die("unused"),
119+ } as unknown as ProjectionSnapshotQuery["Service"]);
120+
121+const makeEngineLayer = (
122+ dispatched: Ref.Ref<ReadonlyArray<OrchestrationCommand>>,
123+ options: { readonly fail?: boolean } = {},
124+) =>
125+ Layer.succeed(OrchestrationEngine.OrchestrationEngineService, {
126+ readEvents: () => Stream.empty,
127+ dispatch: (command: OrchestrationCommand) =>
128+ Ref.update(dispatched, (calls) => [...calls, command]).pipe(
129+ Effect.andThen(
130+ options.fail
131+ ? Effect.fail(
132+ new OrchestrationCommandInvariantError({
133+ commandType: "thread.create",
134+ detail: "Simulated dispatch failure.",
135+ }),
136+ )
137+ : Effect.succeed({ sequence: 1 }),
138+ ),
139+ ),
140+ streamDomainEvents: Stream.empty,
141+ latestSequence: Effect.succeed(0),
142+ } as OrchestrationEngine.OrchestrationEngineService["Service"]);
143+
144+const configLayer = Layer.succeed(ServerConfig, {
145+ baseDir: BASE_DIR,
146+} as unknown as ServerConfig["Service"]);
147+
148+const testLayer = (input: {
149+ readonly threads: Ref.Ref<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>;
150+ readonly dispatched: Ref.Ref<ReadonlyArray<OrchestrationCommand>>;
151+ readonly providerInstances: Record<string, unknown>;
152+ readonly failDispatch?: boolean;
153+ /**
154+ * Archive-state rows. Unlike the shell queue these ignore archive state, so
155+ * a test can model "archived, therefore invisible to the shell query but
156+ * still very much present" — the case that used to mint a second Home.
157+ */
158+ readonly archiveStates?: ReadonlyArray<Option.Option<{ readonly archivedAt: string | null }>>;
159+}) =>
160+ Layer.mergeAll(
161+ makeQueryLayer(input.threads, input.archiveStates),
162+ makeEngineLayer(input.dispatched, input.failDispatch ? { fail: true } : {}),
163+ configLayer,
164+ ServerSettings.layerTest({
165+ providerInstances: input.providerInstances,
166+ } as never),
167+ NodeServices.layer,
168+ );
169+
170+it("reads a designation only from a Hermes envelope", () => {
171+ assert.equal(
172+ readHomeThreadId(hermesInstance({ homeThreadId: HOME_THREAD_ID }) as never),
173+ HOME_THREAD_ID,
174+ );
175+ assert.equal(readHomeThreadId(hermesInstance({}) as never), undefined);
176+ // An empty string is "not designated", not a thread whose id is "".
177+ assert.equal(readHomeThreadId(hermesInstance({ homeThreadId: "" }) as never), undefined);
178+ // A designation on a non-Hermes envelope is not ours to honour.
179+ assert.equal(
180+ readHomeThreadId({ driver: "codex", homeThreadId: HOME_THREAD_ID } as never),
181+ undefined,
182+ );
183+});
184+
185+it.effect("returns the designated thread without dispatching anything", () =>
186+ Effect.gen(function* () {
187+ const threads = yield* Ref.make<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>([
188+ Option.some(threadShell(HOME_THREAD_ID)),
189+ ]);
190+ const dispatched = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
191+
192+ const resolved = yield* getOrCreateHomeThread({
193+ instanceId: INSTANCE_ID,
194+ title: "Hermes Siva Local",
195+ }).pipe(
196+ Effect.provide(
197+ testLayer({
198+ threads,
199+ dispatched,
200+ providerInstances: {
201+ [INSTANCE_ID]: hermesInstance({ homeThreadId: HOME_THREAD_ID }),
202+ },
203+ }),
204+ ),
205+ );
206+
207+ assert.equal(resolved, HOME_THREAD_ID);
208+ // This runs on every handshake; it must not create anything on the happy path.
209+ assert.deepEqual(yield* Ref.get(dispatched), []);
210+ }),
211+);
212+
213+it.effect("creates and persists a Home thread when none is designated", () =>
214+ Effect.gen(function* () {
215+ const threads = yield* Ref.make<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>([
216+ Option.none(),
217+ ]);
218+ const dispatched = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
219+
220+ yield* Effect.gen(function* () {
221+ const resolved = yield* getOrCreateHomeThread({
222+ instanceId: INSTANCE_ID,
223+ title: "Hermes Siva Local",
224+ });
225+
226+ const commands = yield* Ref.get(dispatched);
227+ const created = commands.find((command) => command.type === "thread.create");
228+ assert.isDefined(created);
229+ if (created?.type === "thread.create") {
230+ assert.equal(created.threadId, resolved);
231+ assert.equal(created.title, HOME_THREAD_TITLE);
232+ assert.equal(created.projectId, agentProject.id);
233+ // Binds to the fixed Hermes slug, not to whatever model the plugin
234+ // currently reports — otherwise the thread orphans on a model change.
235+ assert.equal(created.modelSelection.model, DEFAULT_HERMES_MODEL);
236+ }
237+
238+ // The designation must be durable, or the next handshake mints a second
239+ // Home and the first one's history is stranded.
240+ assert.equal(yield* getDesignatedHomeThreadId(INSTANCE_ID), resolved);
241+ }).pipe(
242+ Effect.provide(
243+ testLayer({
244+ threads,
245+ dispatched,
246+ providerInstances: { [INSTANCE_ID]: hermesInstance({}) },
247+ }),
248+ ),
249+ );
250+ }),
251+);
252+
253+it.effect("self-heals a designation whose thread no longer exists", () =>
254+ Effect.gen(function* () {
255+ // A deleted home thread must not strand the instance: it re-designates
256+ // rather than failing every future delivery.
257+ const threads = yield* Ref.make<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>([
258+ Option.none(),
259+ ]);
260+ const dispatched = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
261+
262+ const resolved = yield* getOrCreateHomeThread({
263+ instanceId: INSTANCE_ID,
264+ title: "Hermes Siva Local",
265+ }).pipe(
266+ Effect.provide(
267+ testLayer({
268+ threads,
269+ dispatched,
270+ providerInstances: {
271+ [INSTANCE_ID]: hermesInstance({ homeThreadId: "thread-deleted-long-ago" }),
272+ },
273+ }),
274+ ),
275+ );
276+
277+ assert.notEqual(resolved, "thread-deleted-long-ago");
278+ const commands = yield* Ref.get(dispatched);
279+ assert.isDefined(commands.find((command) => command.type === "thread.create"));
280+ }),
281+);
282+
283+it.effect("adopts a racing caller's designation when its own dispatch fails", () =>
284+ Effect.gen(function* () {
285+ const threads = yield* Ref.make<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>([
286+ Option.none(),
287+ ]);
288+ const dispatched = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
289+
290+ // The loser of the race sees a dispatch failure but must still return the
291+ // winner's thread rather than surfacing an error to the handshake.
292+ const resolved = yield* getOrCreateHomeThread({
293+ instanceId: INSTANCE_ID,
294+ title: "Hermes Siva Local",
295+ }).pipe(
296+ Effect.provide(
297+ testLayer({
298+ threads,
299+ dispatched,
300+ failDispatch: true,
301+ providerInstances: {
302+ // Designated, but the thread read says gone — so it falls through
303+ // to create, fails, and re-reads the designation.
304+ [INSTANCE_ID]: hermesInstance({ homeThreadId: HOME_THREAD_ID }),
305+ },
306+ }),
307+ ),
308+ );
309+
310+ assert.equal(resolved, HOME_THREAD_ID);
311+ }),
312+);
313+
314+it.effect("recognizes any instance's home thread as undeletable", () =>
315+ Effect.gen(function* () {
316+ const threads = yield* Ref.make<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>([]);
317+ const dispatched = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
318+
319+ yield* Effect.gen(function* () {
320+ assert.isTrue(yield* isHomeThread(HOME_THREAD_ID));
321+ // Scans every instance, not just one — the delete path knows only a thread.
322+ assert.isTrue(yield* isHomeThread(ThreadId.make("thread-home-2")));
323+ assert.isFalse(yield* isHomeThread(ThreadId.make("thread-ordinary")));
324+ }).pipe(
325+ Effect.provide(
326+ testLayer({
327+ threads,
328+ dispatched,
329+ providerInstances: {
330+ [INSTANCE_ID]: hermesInstance({ homeThreadId: HOME_THREAD_ID }),
331+ [OTHER_INSTANCE_ID]: hermesInstance({ homeThreadId: "thread-home-2" }),
332+ },
333+ }),
334+ ),
335+ );
336+ }),
337+);
338+
339+it.effect("keeps an archived Home rather than minting a replacement", () =>
340+ Effect.gen(function* () {
341+ // The bug this pins: `getThreadShellById` filters `archived_at IS NULL`, so
342+ // an archived Home read as *deleted* and the self-healing path created a
343+ // second one — stranding the real history and silently re-pointing the
344+ // designation at an empty thread.
345+ const threads = yield* Ref.make<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>([
346+ Option.none(),
347+ ]);
348+ const dispatched = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
349+
350+ const resolved = yield* getOrCreateHomeThread({
351+ instanceId: INSTANCE_ID,
352+ title: "Hermes Siva Local",
353+ }).pipe(
354+ Effect.provide(
355+ testLayer({
356+ threads,
357+ dispatched,
358+ archiveStates: [Option.some({ archivedAt: "2026-01-02T00:00:00.000Z" })],
359+ providerInstances: {
360+ [INSTANCE_ID]: hermesInstance({ homeThreadId: HOME_THREAD_ID }),
361+ },
362+ }),
363+ ),
364+ );
365+
366+ assert.equal(resolved, HOME_THREAD_ID);
367+ const commands = yield* Ref.get(dispatched);
368+ assert.isUndefined(
369+ commands.find((command) => command.type === "thread.create"),
370+ "an archived Home must never be replaced by a new thread",
371+ );
372+ // A delivery is the agent raising its hand, so it brings the thread back
373+ // rather than landing somewhere the user cannot see.
374+ const unarchive = commands.find((command) => command.type === "thread.unarchive");
375+ assert.isDefined(unarchive);
376+ if (unarchive?.type === "thread.unarchive") {
377+ assert.equal(unarchive.threadId, HOME_THREAD_ID);
378+ }
379+ }),
380+);
381+
382+it.effect("does not un-archive a Home that was never archived", () =>
383+ Effect.gen(function* () {
384+ const threads = yield* Ref.make<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>([
385+ Option.some(threadShell(HOME_THREAD_ID)),
386+ ]);
387+ const dispatched = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
388+
389+ yield* getOrCreateHomeThread({
390+ instanceId: INSTANCE_ID,
391+ title: "Hermes Siva Local",
392+ }).pipe(
393+ Effect.provide(
394+ testLayer({
395+ threads,
396+ dispatched,
397+ archiveStates: [Option.some({ archivedAt: null })],
398+ providerInstances: {
399+ [INSTANCE_ID]: hermesInstance({ homeThreadId: HOME_THREAD_ID }),
400+ },
401+ }),
402+ ),
403+ );
404+
405+ // The common path stays free of writes; this runs on every handshake.
406+ assert.deepEqual(yield* Ref.get(dispatched), []);
407+ }),
408+);
apps/server/src/orchestration/homeThreads.ts+242−0
@@ -0,0 +1,242 @@
1+/**
2+ * Home threads — the one thread per agent instance that receives its agent's
3+ * proactive output.
4+ *
5+ * Hermes has a first-class "home channel" concept: a per-platform default
6+ * destination for output nobody asked for at a specific address — a cron job's
7+ * result, an agent-initiated `send_message`, a gateway online notice, a
8+ * `/handoff`. Every other Hermes surface designates one interactively via
9+ * `/sethome`. T3 designates one automatically instead, because there is no
10+ * "current channel" here for a user to point at: threads are created on
11+ * demand, and asking someone to pick one before their first cron job can fire
12+ * is setup for a decision they have no basis to make.
13+ *
14+ * ## Converge-on-read, like agent projects
15+ *
16+ * `getOrCreateHomeThread` is a **precondition every caller runs**, not a
17+ * lifecycle event one caller owns — the same reasoning as
18+ * `getOrCreateAgentProject`, and for the same failure modes: an instance
19+ * enrolled before this shipped has no designation, a dispatch that failed once
20+ * would otherwise leave the instance permanently unable to receive, and a
21+ * thread deleted out from under the designation would strand every future
22+ * delivery. Each of those self-heals on the next handshake.
23+ *
24+ * ## Why settings and not a table
25+ *
26+ * The designation is per-instance enrollment metadata, exactly like
27+ * `connectorUrl` and `revoked`, and it lives beside them in the Hermes
28+ * instance's config blob. That keeps one source of truth for "facts about this
29+ * enrollment" and avoids a migration for a single nullable id.
30+ *
31+ * The plugin's `T3_HOME_CHANNEL` env var is a *cache* of this value, reconciled
32+ * on every `connection.accepted`. This module is authoritative; drift is
33+ * bounded by one reconnect.
34+ *
35+ * @module orchestration/homeThreads
36+ */
37+import {
38+ CommandId,
39+ DEFAULT_HERMES_MODEL,
40+ DEFAULT_PROVIDER_INTERACTION_MODE,
41+ HERMES_DRIVER_KIND,
42+ ThreadId,
43+ type ProviderInstanceConfig,
44+ type ProviderInstanceId,
45+} from "@t3tools/contracts";
46+import * as Cause from "effect/Cause";
47+import * as Crypto from "effect/Crypto";
48+import * as DateTime from "effect/DateTime";
49+import * as Effect from "effect/Effect";
50+import * as Option from "effect/Option";
51+
52+import { ServerSettingsService } from "../serverSettings.ts";
53+import { getOrCreateAgentProject } from "./agentProjects.ts";
54+import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
55+import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
56+
57+/**
58+ * Title of the home thread. Plain, no emoji: it sits in the same list as the
59+ * user's own threads and should not shout.
60+ */
61+export const HOME_THREAD_TITLE = "Home";
62+
63+/** Read the designated home thread id out of a Hermes instance envelope. */
64+export const readHomeThreadId = (
65+ config: ProviderInstanceConfig | undefined,
66+): ThreadId | undefined => {
67+ if (!config || config.driver !== HERMES_DRIVER_KIND) return undefined;
68+ const blob = config.config && typeof config.config === "object" ? config.config : {};
69+ const raw = (blob as Record<string, unknown>).homeThreadId;
70+ return typeof raw === "string" && raw !== "" ? ThreadId.make(raw) : undefined;
71+};
72+
73+/**
74+ * Read one instance's designation straight from settings.
75+ *
76+ * Callers that only need to *know* the designation — the snapshot builder, the
77+ * delete guard — use this rather than `getOrCreateHomeThread`, so a read never
78+ * has the side effect of creating a thread.
79+ */
80+export const getDesignatedHomeThreadId = Effect.fn("getDesignatedHomeThreadId")(function* (
81+ instanceId: ProviderInstanceId,
82+) {
83+ const settings = yield* ServerSettingsService;
84+ const current = yield* settings.getSettings;
85+ return readHomeThreadId(current.providerInstances[instanceId]);
86+});
87+
88+/**
89+ * Whether a thread is some instance's home thread, and therefore undeletable.
90+ *
91+ * Scans every configured instance rather than taking an instance id, because
92+ * the delete path knows only the thread. The map is small — one entry per
93+ * configured provider instance — and this runs once per delete.
94+ */
95+export const isHomeThread = Effect.fn("isHomeThread")(function* (threadId: ThreadId) {
96+ const settings = yield* ServerSettingsService;
97+ const current = yield* settings.getSettings;
98+ for (const config of Object.values(current.providerInstances)) {
99+ if (readHomeThreadId(config) === threadId) return true;
100+ }
101+ return false;
102+});
103+
104+/**
105+ * Read the designated thread's row regardless of archive state.
106+ *
107+ * The snapshot query's thread reads filter `archived_at IS NULL`, which is
108+ * correct for "what should the user see" and wrong for "does this row exist".
109+ * Going to the repository directly is what keeps an archived Home from looking
110+ * deleted — and thereby keeps this module from minting a second one. A
111+ * soft-deleted row still counts as gone.
112+ */
113+const readHomeThreadRow = Effect.fn("readHomeThreadRow")(function* (threadId: ThreadId) {
114+ const query = yield* ProjectionSnapshotQuery;
115+ const row = yield* query.getThreadArchiveStateById(threadId);
116+ return Option.getOrUndefined(row);
117+});
118+
119+/** Persist a designation into the instance's Hermes config blob. */
120+const persistHomeThreadId = (input: {
121+ readonly instanceId: ProviderInstanceId;
122+ readonly threadId: ThreadId;
123+}) =>
124+ Effect.gen(function* () {
125+ const settings = yield* ServerSettingsService;
126+ yield* settings.updateSettingsWith((latest) => {
127+ // Re-read under the settings write lock: the envelope may have been
128+ // replaced (or the driver changed) between our read and this write, and
129+ // writing a Hermes designation onto a non-Hermes envelope would corrupt
130+ // it. Returning an empty patch is the established no-op here.
131+ const existing = latest.providerInstances[input.instanceId];
132+ if (!existing || existing.driver !== HERMES_DRIVER_KIND) return {};
133+ const currentConfig =
134+ existing.config && typeof existing.config === "object"
135+ ? (existing.config as Record<string, unknown>)
136+ : {};
137+ return {
138+ providerInstances: {
139+ ...latest.providerInstances,
140+ [input.instanceId]: {
141+ ...existing,
142+ config: { ...currentConfig, homeThreadId: input.threadId },
143+ },
144+ },
145+ };
146+ });
147+ });
148+
149+/**
150+ * Resolve the home thread for one provider instance, creating it if absent.
151+ *
152+ * Safe to call on every handshake: the common path is one settings read plus
153+ * one indexed thread read. Idempotent, and concurrent callers converge —
154+ * the loser of a race re-reads and adopts the winner's thread.
155+ */
156+export const getOrCreateHomeThread = Effect.fn("getOrCreateHomeThread")(function* (input: {
157+ readonly instanceId: ProviderInstanceId;
158+ /** Instance nickname, used as the agent project's title on first creation. */
159+ readonly title: string;
160+}) {
161+ const engine = yield* OrchestrationEngineService;
162+ const crypto = yield* Crypto.Crypto;
163+
164+ const designated = yield* getDesignatedHomeThreadId(input.instanceId);
165+ if (designated !== undefined) {
166+ // Deliberately NOT `getThreadShellById`: that query filters
167+ // `archived_at IS NULL`, so an archived Home reads as absent and this
168+ // function would "self-heal" by minting a second Home — stranding the
169+ // history in the archived one and silently re-pointing the designation.
170+ // Archiving is a user parking a thread, not destroying it; only a row
171+ // that is really gone justifies a replacement.
172+ const existing = yield* readHomeThreadRow(designated);
173+ if (existing !== undefined) {
174+ // Un-archive rather than deliver into a hidden thread. A delivery is
175+ // the agent raising its hand, and the same rule the decider applies to
176+ // settled/snoozed threads applies here: incoming activity brings the
177+ // thread back. Best-effort — a failure here must not cost the delivery.
178+ if (existing.archivedAt !== null) {
179+ yield* engine
180+ .dispatch({
181+ type: "thread.unarchive",
182+ commandId: CommandId.make(yield* crypto.randomUUIDv4),
183+ threadId: designated,
184+ })
185+ .pipe(
186+ Effect.catchCause((cause) =>
187+ Effect.logWarning("could not un-archive the home thread", {
188+ instanceId: input.instanceId,
189+ threadId: designated,
190+ cause: Cause.pretty(cause),
191+ }),
192+ ),
193+ );
194+ }
195+ return designated;
196+ }
197+ }
198+
199+ const project = yield* getOrCreateAgentProject({
200+ instanceId: input.instanceId,
201+ title: input.title,
202+ });
203+
204+ const threadId = ThreadId.make(yield* crypto.randomUUIDv4);
205+ const createdAt = DateTime.formatIso(yield* DateTime.now);
206+
207+ const dispatched = yield* Effect.result(
208+ engine.dispatch({
209+ type: "thread.create",
210+ commandId: CommandId.make(yield* crypto.randomUUIDv4),
211+ threadId,
212+ projectId: project.id,
213+ title: HOME_THREAD_TITLE,
214+ // The same fixed slug every Hermes thread binds to — the reported model
215+ // name is display-only, and following it here would orphan the thread
216+ // whenever Hermes' own config changed.
217+ modelSelection: { instanceId: input.instanceId, model: DEFAULT_HERMES_MODEL },
218+ runtimeMode: "full-access",
219+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
220+ branch: null,
221+ worktreePath: null,
222+ createdAt,
223+ }),
224+ );
225+
226+ if (dispatched._tag === "Failure") {
227+ // A concurrent handshake may have won and already persisted its thread.
228+ // Re-read before surfacing the error, matching getOrCreateAgentProject.
229+ const raced = yield* getDesignatedHomeThreadId(input.instanceId);
230+ if (raced !== undefined) return raced;
231+ return yield* Effect.fail(dispatched.failure);
232+ }
233+
234+ yield* persistHomeThreadId({ instanceId: input.instanceId, threadId });
235+
236+ // Re-read rather than trusting our own write: if a racing caller persisted
237+ // first, both callers must agree on one thread, and settings is the
238+ // tiebreaker. Our orphaned thread stays as an empty thread in the project
239+ // rather than becoming a second home.
240+ const settled = yield* getDesignatedHomeThreadId(input.instanceId);
241+ return settled ?? threadId;
242+});
apps/server/src/orchestration/projector.notification.test.ts+149−0
@@ -0,0 +1,149 @@
1+import {
2+ CommandId,
3+ EventId,
4+ MessageId,
5+ ProjectId,
6+ ProviderInstanceId,
7+ ThreadId,
8+ type OrchestrationEvent,
9+} from "@t3tools/contracts";
10+import { expect, it } from "@effect/vitest";
11+import * as Effect from "effect/Effect";
12+
13+import { createEmptyReadModel, projectEvent } from "./projector.ts";
14+
15+const CREATED_AT = "2026-07-01T09:00:00.000Z";
16+const DELIVERED_AT = "2026-07-01T09:05:00.000Z";
17+
18+function makeEvent(input: {
19+ readonly sequence: number;
20+ readonly type: OrchestrationEvent["type"];
21+ readonly occurredAt: string;
22+ readonly payload: unknown;
23+}): OrchestrationEvent {
24+ return {
25+ sequence: input.sequence,
26+ eventId: EventId.make(`event-${input.sequence}`),
27+ type: input.type,
28+ aggregateKind: "thread",
29+ aggregateId: ThreadId.make("thread-home"),
30+ occurredAt: input.occurredAt,
31+ commandId: CommandId.make(`command-${input.sequence}`),
32+ causationEventId: null,
33+ correlationId: null,
34+ metadata: {},
35+ payload: input.payload as never,
36+ } as OrchestrationEvent;
37+}
38+
39+const threadCreatedEvent = makeEvent({
40+ sequence: 1,
41+ type: "thread.created",
42+ occurredAt: CREATED_AT,
43+ payload: {
44+ threadId: ThreadId.make("thread-home"),
45+ projectId: ProjectId.make("project-1"),
46+ title: "Home",
47+ modelSelection: { instanceId: ProviderInstanceId.make("hermes"), model: "hermes" },
48+ runtimeMode: "full-access",
49+ branch: null,
50+ worktreePath: null,
51+ createdAt: CREATED_AT,
52+ updatedAt: CREATED_AT,
53+ },
54+});
55+
56+const deliveryEvent = makeEvent({
57+ sequence: 2,
58+ type: "thread.notification-delivered",
59+ occurredAt: DELIVERED_AT,
60+ payload: {
61+ threadId: ThreadId.make("thread-home"),
62+ messageId: MessageId.make("notification:msg-1"),
63+ text: "Digest ready.",
64+ notification: {
65+ kind: "cron",
66+ label: "Cron: daily-digest",
67+ deliveryId: "delivery-1",
68+ },
69+ createdAt: DELIVERED_AT,
70+ updatedAt: DELIVERED_AT,
71+ },
72+});
73+
74+const expectedMessage = {
75+ id: "notification:msg-1",
76+ role: "assistant",
77+ text: "Digest ready.",
78+ // A delivery belongs to no turn and is never streamed.
79+ turnId: null,
80+ streaming: false,
81+ notification: {
82+ kind: "cron",
83+ label: "Cron: daily-digest",
84+ deliveryId: "delivery-1",
85+ },
86+ createdAt: DELIVERED_AT,
87+ updatedAt: DELIVERED_AT,
88+};
89+
90+it.effect("appends a notification delivery as a turnless assistant message", () =>
91+ Effect.gen(function* () {
92+ const created = yield* projectEvent(createEmptyReadModel(CREATED_AT), threadCreatedEvent);
93+ const delivered = yield* projectEvent(created, deliveryEvent);
94+
95+ const thread = delivered.threads[0];
96+ expect(thread?.messages).toHaveLength(1);
97+ expect(thread?.messages[0]).toEqual(expectedMessage);
98+ expect(thread?.updatedAt).toBe(DELIVERED_AT);
99+ // Nothing about a delivery touches turn state — attributing it to the
100+ // thread's live turn would fold it behind that turn's summary.
101+ expect(thread?.latestTurn).toBeNull();
102+ }),
103+);
104+
105+it.effect("replays a delivery onto the same message instead of duplicating it", () =>
106+ Effect.gen(function* () {
107+ const created = yield* projectEvent(createEmptyReadModel(CREATED_AT), threadCreatedEvent);
108+ const delivered = yield* projectEvent(created, deliveryEvent);
109+ // The decider re-emits the persisted row for a replayed deliveryId, so
110+ // reducing the event twice must be a no-op rather than an append.
111+ const replayed = yield* projectEvent(delivered, { ...deliveryEvent, sequence: 3 });
112+
113+ expect(replayed.threads[0]?.messages).toHaveLength(1);
114+ expect(replayed.threads[0]?.messages[0]).toEqual(expectedMessage);
115+ }),
116+);
117+
118+it.effect("keeps deliveries alongside ordinary turn messages", () =>
119+ Effect.gen(function* () {
120+ const created = yield* projectEvent(createEmptyReadModel(CREATED_AT), threadCreatedEvent);
121+ const withUserMessage = yield* projectEvent(
122+ created,
123+ makeEvent({
124+ sequence: 2,
125+ type: "thread.message-sent",
126+ occurredAt: "2026-07-01T09:01:00.000Z",
127+ payload: {
128+ threadId: ThreadId.make("thread-home"),
129+ messageId: MessageId.make("msg-user"),
130+ role: "user",
131+ text: "hi",
132+ turnId: null,
133+ streaming: false,
134+ createdAt: "2026-07-01T09:01:00.000Z",
135+ updatedAt: "2026-07-01T09:01:00.000Z",
136+ },
137+ }),
138+ );
139+ const delivered = yield* projectEvent(withUserMessage, { ...deliveryEvent, sequence: 3 });
140+
141+ expect(delivered.threads[0]?.messages.map((message) => message.id)).toEqual([
142+ "msg-user",
143+ "notification:msg-1",
144+ ]);
145+ // Absence of `notification` is what marks an ordinary message; it must
146+ // never be synthesized onto one.
147+ expect(delivered.threads[0]?.messages[0]?.notification).toBeUndefined();
148+ }),
149+);
apps/server/src/orchestration/projector.ts+50−0
@@ -21,6 +21,7 @@ import { · 20 unmodified lines
2121 ThreadDeletedPayload,
2222 ThreadInteractionModeSetPayload,
2323 ThreadMetaUpdatedPayload,
24+ ThreadNotificationDeliveredPayload,
2425 ThreadProposedPlanUpsertedPayload,
2526 ThreadRuntimeModeSetPayload,
2627 ThreadSettledPayload,
@@ -722,6 +723,55 @@ export function projectEvent( · 695 unmodified lines
722723 }),
723724 );
724725
726+ case "thread.notification-delivered":
727+ return Effect.gen(function* () {
728+ const payload = yield* decodeForEvent(
729+ ThreadNotificationDeliveredPayload,
730+ event.payload,
731+ event.type,
732+ "payload",
733+ );
734+ const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
735+ if (!thread) {
736+ return nextBase;
737+ }
738+
739+ const message: OrchestrationMessage = yield* decodeForEvent(
740+ OrchestrationMessage,
741+ {
742+ id: payload.messageId,
743+ role: "assistant",
744+ text: payload.text,
745+ // A delivery belongs to no turn: it arrives outside the turn
746+ // machinery and may land while an unrelated turn is live.
747+ turnId: null,
748+ streaming: false,
749+ notification: payload.notification,
750+ createdAt: payload.createdAt,
751+ updatedAt: payload.updatedAt,
752+ },
753+ event.type,
754+ "message",
755+ );
756+
757+ // Replays of an already-written delivery re-emit the persisted row, so
758+ // replacing in place is a byte-identical no-op rather than a duplicate.
759+ const existingMessage = thread.messages.find((entry) => entry.id === message.id);
760+ const messages = (
761+ existingMessage
762+ ? thread.messages.map((entry) => (entry.id === message.id ? message : entry))
763+ : [...thread.messages, message]
764+ ).slice(-MAX_THREAD_MESSAGES);
765+
766+ return {
767+ ...nextBase,
768+ threads: updateThread(nextBase.threads, payload.threadId, {
769+ messages,
770+ updatedAt: event.occurredAt,
771+ }),
772+ };
773+ });
774+
725775 case "thread.activity-appended":
726776 return decodeForEvent(
727777 ThreadActivityAppendedPayload,
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+2−0
@@ -100,6 +100,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { · 99 unmodified lines
100100 pendingApprovalCount: 0,
101101 pendingUserInputCount: 0,
102102 hasActionableProposedPlan: 0,
103+ latestNotification: null,
103104 deletedAt: null,
104105 });
105106
@@ -162,6 +163,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { · 56 unmodified lines
162163 pendingApprovalCount: 0,
163164 pendingUserInputCount: 0,
164165 hasActionableProposedPlan: 0,
166+ latestNotification: null,
165167 deletedAt: null,
166168 });
167169
apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts+74−0
@@ -66,6 +66,80 @@ layer("ProjectionThreadMessageRepository", (it) => { · 65 unmodified lines
6666 }),
6767 );
6868
69+ it.effect("round-trips notification provenance and preserves it across later upserts", () =>
70+ Effect.gen(function* () {
71+ const repository = yield* ProjectionThreadMessageRepository;
72+ const threadId = ThreadId.make("thread-notification");
73+ const messageId = MessageId.make("message-notification");
74+ const createdAt = "2026-07-01T09:00:00.000Z";
75+ const notification = {
76+ kind: "cron" as const,
77+ label: "Cron: daily-digest",
78+ deliveryId: "delivery-1",
79+ };
80+
81+ yield* repository.upsert({
82+ messageId,
83+ threadId,
84+ turnId: null,
85+ role: "assistant",
86+ text: "Digest ready.",
87+ notification,
88+ isStreaming: false,
89+ createdAt,
90+ updatedAt: createdAt,
91+ });
92+
93+ const rows = yield* repository.listByThreadId({ threadId });
94+ assert.equal(rows.length, 1);
95+ assert.deepEqual(rows[0]?.notification, notification);
96+
97+ // Same COALESCE rule as attachments: an unrelated update that omits the
98+ // field must not erase a delivery's provenance.
99+ yield* repository.upsert({
100+ messageId,
101+ threadId,
102+ turnId: null,
103+ role: "assistant",
104+ text: "Digest ready (edited).",
105+ isStreaming: false,
106+ createdAt,
107+ updatedAt: "2026-07-01T09:00:02.000Z",
108+ });
109+
110+ const rowById = yield* repository.getByMessageId({ messageId });
111+ assert.equal(rowById._tag, "Some");
112+ if (rowById._tag === "Some") {
113+ assert.equal(rowById.value.text, "Digest ready (edited).");
114+ assert.deepEqual(rowById.value.notification, notification);
115+ }
116+ }),
117+ );
118+
119+ it.effect("leaves notification absent on ordinary messages", () =>
120+ Effect.gen(function* () {
121+ const repository = yield* ProjectionThreadMessageRepository;
122+ const threadId = ThreadId.make("thread-ordinary-message");
123+ const messageId = MessageId.make("message-ordinary");
124+
125+ yield* repository.upsert({
126+ messageId,
127+ threadId,
128+ turnId: null,
129+ role: "user",
130+ text: "hello",
131+ isStreaming: false,
132+ createdAt: "2026-07-01T09:10:00.000Z",
133+ updatedAt: "2026-07-01T09:10:00.000Z",
134+ });
135+
136+ const rows = yield* repository.listByThreadId({ threadId });
137+ // Absent, not a synthesized default — consumers key on its absence to
138+ // tell an ordinary message from a proactive delivery.
139+ assert.equal(rows[0]?.notification, undefined);
140+ }),
141+ );
142+
69143 it.effect("allows explicit attachment clearing with an empty array", () =>
70144 Effect.gen(function* () {
71145 const repository = yield* ProjectionThreadMessageRepository;
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts+20−1
@@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; · 4 unmodified lines
55 import * as Option from "effect/Option";
66 import * as Schema from "effect/Schema";
77 import * as Struct from "effect/Struct";
8−import { ChatAttachment } from "@t3tools/contracts";
8+import { ChatAttachment, OrchestrationMessageNotification } from "@t3tools/contracts";
99
1010 import { toPersistenceSqlError } from "../Errors.ts";
1111 import {
@@ -21,6 +21,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( · 9 unmodified lines
2121 Struct.assign({
2222 isStreaming: Schema.Number,
2323 attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))),
24+ notification: Schema.NullOr(Schema.fromJsonString(OrchestrationMessageNotification)),
2425 }),
2526 );
2627
@@ -37,6 +38,7 @@ function toProjectionThreadMessage( · 10 unmodified lines
3738 createdAt: row.createdAt,
3839 updatedAt: row.updatedAt,
3940 ...(row.attachments !== null ? { attachments: row.attachments } : {}),
41+ ...(row.notification !== null ? { notification: row.notification } : {}),
4042 };
4143 }
4244
@@ -48,6 +50,8 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { · 5 unmodified lines
4850 execute: (row) => {
4951 const nextAttachmentsJson =
5052 row.attachments !== undefined ? JSON.stringify(row.attachments) : null;
53+ const nextNotificationJson =
54+ row.notification !== undefined ? JSON.stringify(row.notification) : null;
5155 return sql`
5256 INSERT INTO projection_thread_messages (
5357 message_id,
@@ -56,6 +60,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { · 2 unmodified lines
5660 role,
5761 text,
5862 attachments_json,
63+ notification_json,
5964 is_streaming,
6065 created_at,
6166 updated_at
@@ -74,6 +79,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { · 12 unmodified lines
7479 WHERE message_id = ${row.messageId}
7580 )
7681 ),
82+ COALESCE(
83+ ${nextNotificationJson},
84+ (
85+ SELECT notification_json
86+ FROM projection_thread_messages
87+ WHERE message_id = ${row.messageId}
88+ )
89+ ),
7790 ${row.isStreaming ? 1 : 0},
7891 ${row.createdAt},
7992 ${row.updatedAt}
@@ -88,6 +101,10 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { · 8 unmodified lines
88101 excluded.attachments_json,
89102 projection_thread_messages.attachments_json
90103 ),
104+ notification_json = COALESCE(
105+ excluded.notification_json,
106+ projection_thread_messages.notification_json
107+ ),
91108 is_streaming = excluded.is_streaming,
92109 created_at = excluded.created_at,
93110 updated_at = excluded.updated_at
@@ -107,6 +124,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { · 13 unmodified lines
107124 role,
108125 text,
109126 attachments_json AS "attachments",
127+ notification_json AS "notification",
110128 is_streaming AS "isStreaming",
111129 created_at AS "createdAt",
112130 updated_at AS "updatedAt"
@@ -128,6 +146,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { · 15 unmodified lines
128146 role,
129147 text,
130148 attachments_json AS "attachments",
149+ notification_json AS "notification",
131150 is_streaming AS "isStreaming",
132151 created_at AS "createdAt",
133152 updated_at AS "updatedAt"
apps/server/src/persistence/Layers/ProjectionThreads.ts+15−4
@@ -14,11 +14,14 @@ import { · 13 unmodified lines
1414 ProjectionThreadRepository,
1515 type ProjectionThreadRepositoryShape,
1616 } from "../Services/ProjectionThreads.ts";
17−import { ModelSelection } from "@t3tools/contracts";
17+import { ModelSelection, OrchestrationThreadNotificationSummary } from "@t3tools/contracts";
1818
1919 const ProjectionThreadDbRow = ProjectionThread.mapFields(
2020 Struct.assign({
2121 modelSelection: Schema.fromJsonString(ModelSelection),
22+ latestNotification: Schema.NullOr(
23+ Schema.fromJsonString(OrchestrationThreadNotificationSummary),
24+ ),
2225 }),
2326 );
2427 type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type;
@@ -28,8 +31,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 3 unmodified lines
2831
2932 const upsertProjectionThreadRow = SqlSchema.void({
3033 Request: ProjectionThread,
31− execute: (row) =>
32− sql`
34+ execute: (row) => {
35+ const latestNotificationJson =
36+ row.latestNotification === null ? null : JSON.stringify(row.latestNotification);
37+ return sql`
3338 INSERT INTO projection_threads (
3439 thread_id,
3540 project_id,
@@ -51,6 +56,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 15 unmodified lines
5156 pending_approval_count,
5257 pending_user_input_count,
5358 has_actionable_proposed_plan,
59+ latest_notification_json,
5460 deleted_at
5561 )
5662 VALUES (
@@ -74,6 +80,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 17 unmodified lines
7480 ${row.pendingApprovalCount},
7581 ${row.pendingUserInputCount},
7682 ${row.hasActionableProposedPlan},
83+ ${latestNotificationJson},
7784 ${row.deletedAt}
7885 )
7986 ON CONFLICT (thread_id)
@@ -97,8 +104,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 17 unmodified lines
97104 pending_approval_count = excluded.pending_approval_count,
98105 pending_user_input_count = excluded.pending_user_input_count,
99106 has_actionable_proposed_plan = excluded.has_actionable_proposed_plan,
107+ latest_notification_json = excluded.latest_notification_json,
100108 deleted_at = excluded.deleted_at
101− `,
109+ `;
110+ },
102111 });
103112
104113 const getProjectionThreadRow = SqlSchema.findOneOption({
@@ -127,6 +136,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 22 unmodified lines
127136 pending_approval_count AS "pendingApprovalCount",
128137 pending_user_input_count AS "pendingUserInputCount",
129138 has_actionable_proposed_plan AS "hasActionableProposedPlan",
139+ latest_notification_json AS "latestNotification",
130140 deleted_at AS "deletedAt"
131141 FROM projection_threads
132142 WHERE thread_id = ${threadId}
@@ -159,6 +169,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 26 unmodified lines
159169 pending_approval_count AS "pendingApprovalCount",
160170 pending_user_input_count AS "pendingUserInputCount",
161171 has_actionable_proposed_plan AS "hasActionableProposedPlan",
172+ latest_notification_json AS "latestNotification",
162173 deleted_at AS "deletedAt"
163174 FROM projection_threads
164175 WHERE project_id = ${projectId}
apps/server/src/persistence/Migrations.ts+4−0
@@ -48,6 +48,8 @@ import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; · 47 unmodified lines
4848 import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts";
4949 import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts";
5050 import Migration0035 from "./Migrations/035_ProjectionProjectsAgentInstance.ts";
51+import Migration0036 from "./Migrations/036_ProjectionThreadMessageNotification.ts";
52+import Migration0037 from "./Migrations/037_ProjectionThreadsLatestNotification.ts";
5153
5254 /**
5355 * Migration loader with all migrations defined inline.
@@ -95,6 +97,8 @@ export const migrationEntries = [ · 41 unmodified lines
9597 [33, "ProjectionThreadsSettled", Migration0033],
9698 [34, "ProjectionThreadsSnoozed", Migration0034],
9799 [35, "ProjectionProjectsAgentInstance", Migration0035],
100+ [36, "ProjectionThreadMessageNotification", Migration0036],
101+ [37, "ProjectionThreadsLatestNotification", Migration0037],
98102 ] as const;
99103
100104 export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/036_ProjectionThreadMessageNotification.test.ts+109−0
@@ -0,0 +1,109 @@
1+import { assert, it } from "@effect/vitest";
2+import { OrchestrationMessageNotification } from "@t3tools/contracts";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import * as Schema from "effect/Schema";
6+import * as SqlClient from "effect/unstable/sql/SqlClient";
7+
8+import { runMigrations } from "../Migrations.ts";
9+import * as NodeSqliteClient from "../NodeSqliteClient.ts";
10+
11+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
12+
13+layer("036_ProjectionThreadMessageNotification", (it) => {
14+ it.effect("adds the notification column without disturbing existing messages", () =>
15+ Effect.gen(function* () {
16+ const sql = yield* SqlClient.SqlClient;
17+
18+ yield* runMigrations({ toMigrationInclusive: 35 });
19+
20+ // An ordinary message written before the column existed. Proactive
21+ // deliveries are the exception, so every pre-existing row must survive
22+ // the migration with no notification rather than a synthesized default.
23+ yield* sql`
24+ INSERT INTO projection_thread_messages (
25+ message_id,
26+ thread_id,
27+ turn_id,
28+ role,
29+ text,
30+ is_streaming,
31+ created_at,
32+ updated_at
33+ ) VALUES (
34+ 'message-1',
35+ 'thread-1',
36+ 'turn-1',
37+ 'assistant',
38+ 'an ordinary answer',
39+ 0,
40+ '2026-01-01T00:00:00.000Z',
41+ '2026-01-01T00:00:00.000Z'
42+ )
43+ `;
44+
45+ yield* runMigrations({ toMigrationInclusive: 36 });
46+
47+ const rows = yield* sql<{
48+ readonly message_id: string;
49+ readonly text: string;
50+ readonly notification_json: string | null;
51+ }>`
52+ SELECT message_id, text, notification_json
53+ FROM projection_thread_messages
54+ WHERE message_id = 'message-1'
55+ `;
56+
57+ assert.equal(rows.length, 1);
58+ assert.equal(rows[0]?.text, "an ordinary answer");
59+ assert.equal(rows[0]?.notification_json, null);
60+
61+ // And the new column accepts a delivery's provenance.
62+ yield* sql`
63+ INSERT INTO projection_thread_messages (
64+ message_id,
65+ thread_id,
66+ turn_id,
67+ role,
68+ text,
69+ is_streaming,
70+ created_at,
71+ updated_at,
72+ notification_json
73+ ) VALUES (
74+ 'message-2',
75+ 'thread-1',
76+ NULL,
77+ 'assistant',
78+ 'Your digest is ready.',
79+ 0,
80+ '2026-01-01T00:01:00.000Z',
81+ '2026-01-01T00:01:00.000Z',
82+ '{"kind":"cron","label":"Cron: daily-digest","deliveryId":"delivery-1"}'
83+ )
84+ `;
85+
86+ const delivered = yield* sql<{
87+ readonly notification_json: string | null;
88+ readonly turn_id: string | null;
89+ }>`
90+ SELECT notification_json, turn_id
91+ FROM projection_thread_messages
92+ WHERE message_id = 'message-2'
93+ `;
94+
95+ assert.equal(delivered.length, 1);
96+ // A delivery belongs to no turn — attributing it to the thread's live
97+ // turn would fold it away behind that turn's "Worked for …" row.
98+ assert.equal(delivered[0]?.turn_id, null);
99+ const decodedNotification = yield* Schema.decodeUnknownEffect(
100+ Schema.fromJsonString(OrchestrationMessageNotification),
101+ )(delivered[0]?.notification_json ?? "");
102+ assert.deepEqual(decodedNotification, {
103+ kind: "cron",
104+ label: "Cron: daily-digest",
105+ deliveryId: "delivery-1",
106+ });
107+ }),
108+ );
109+});
apps/server/src/persistence/Migrations/036_ProjectionThreadMessageNotification.ts+11−0
@@ -0,0 +1,11 @@
1+import * as SqlClient from "effect/unstable/sql/SqlClient";
2+import * as Effect from "effect/Effect";
3+
4+export default Effect.gen(function* () {
5+ const sql = yield* SqlClient.SqlClient;
6+
7+ yield* sql`
8+ ALTER TABLE projection_thread_messages
9+ ADD COLUMN notification_json TEXT
10+ `;
11+});
apps/server/src/persistence/Migrations/037_ProjectionThreadsLatestNotification.test.ts+80−0
@@ -0,0 +1,80 @@
1+import { assert, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import * as Layer from "effect/Layer";
4+import * as SqlClient from "effect/unstable/sql/SqlClient";
5+
6+import { runMigrations } from "../Migrations.ts";
7+import * as NodeSqliteClient from "../NodeSqliteClient.ts";
8+
9+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10+
11+layer("037_ProjectionThreadsLatestNotification", (it) => {
12+ it.effect("adds a nullable latest_notification_json column, leaving existing rows null", () =>
13+ Effect.gen(function* () {
14+ const sql = yield* SqlClient.SqlClient;
15+
16+ yield* runMigrations({ toMigrationInclusive: 36 });
17+
18+ const before = yield* sql<{ readonly name: string }>`
19+ PRAGMA table_info(projection_threads)
20+ `;
21+ assert.ok(!before.some((column) => column.name === "latest_notification_json"));
22+
23+ yield* sql`
24+ INSERT INTO projection_threads (
25+ thread_id,
26+ project_id,
27+ title,
28+ model_selection_json,
29+ runtime_mode,
30+ interaction_mode,
31+ branch,
32+ worktree_path,
33+ latest_turn_id,
34+ created_at,
35+ updated_at,
36+ deleted_at
37+ )
38+ VALUES (
39+ 'thread-pre-037',
40+ 'project-pre-037',
41+ 'Existing thread',
42+ '{"instanceId":"codex","model":"gpt-5.4"}',
43+ 'full-access',
44+ 'default',
45+ NULL,
46+ NULL,
47+ NULL,
48+ '2026-07-01T00:00:00.000Z',
49+ '2026-07-01T00:00:00.000Z',
50+ NULL
51+ )
52+ `;
53+
54+ yield* runMigrations({ toMigrationInclusive: 37 });
55+
56+ const after = yield* sql<{
57+ readonly name: string;
58+ readonly type: string;
59+ readonly notnull: number;
60+ readonly dflt_value: string | null;
61+ }>`
62+ PRAGMA table_info(projection_threads)
63+ `;
64+ const column = after.find((entry) => entry.name === "latest_notification_json");
65+ assert.ok(column);
66+ assert.strictEqual(column.type, "TEXT");
67+ assert.strictEqual(column.notnull, 0);
68+ assert.strictEqual(column.dflt_value, null);
69+
70+ // No backfill is needed or possible: notification_json (migration 036)
71+ // is newer than every pre-existing row, so nothing to summarize.
72+ const rows = yield* sql<{ readonly latestNotification: string | null }>`
73+ SELECT latest_notification_json AS "latestNotification"
74+ FROM projection_threads
75+ WHERE thread_id = 'thread-pre-037'
76+ `;
77+ assert.deepStrictEqual(rows, [{ latestNotification: null }]);
78+ }),
79+ );
80+});
apps/server/src/persistence/Migrations/037_ProjectionThreadsLatestNotification.ts+22−0
@@ -0,0 +1,22 @@
1+import * as SqlClient from "effect/unstable/sql/SqlClient";
2+import * as Effect from "effect/Effect";
3+
4+/**
5+ * Materializes the thread's newest proactive delivery onto the shell row.
6+ *
7+ * Shell consumers (sidebar, agent-awareness/push) never read a thread's
8+ * messages, so notification provenance that lives on the message row is
9+ * invisible to them unless it is summarized here — the same reason
10+ * `latest_user_message_at` exists.
11+ *
12+ * No backfill: `projection_thread_messages.notification_json` is introduced by
13+ * migration 036, so no delivery predates this column.
14+ */
15+export default Effect.gen(function* () {
16+ const sql = yield* SqlClient.SqlClient;
17+
18+ yield* sql`
19+ ALTER TABLE projection_threads
20+ ADD COLUMN latest_notification_json TEXT
21+ `;
22+});
apps/server/src/persistence/Services/ProjectionThreadMessages.ts+9−0
@@ -9,6 +9,7 @@ · 8 unmodified lines
99 import {
1010 ChatAttachment,
1111 MessageId,
12+ OrchestrationMessageNotification,
1213 OrchestrationMessageRole,
1314 ThreadId,
1415 TurnId,
@@ -28,6 +29,14 @@ export const ProjectionThreadMessage = Schema.Struct({ · 13 unmodified lines
2829 role: OrchestrationMessageRole,
2930 text: Schema.String,
3031 attachments: Schema.optional(Schema.Array(ChatAttachment)),
32+ /**
33+ * Proactive-delivery provenance, present only on notification rows.
34+ *
35+ * Optional like `attachments`, and persisted with the same
36+ * `COALESCE(excluded, existing)` upsert rule: an ordinary update that omits
37+ * it must not erase the provenance of a row that has it.
38+ */
39+ notification: Schema.optional(OrchestrationMessageNotification),
3140 isStreaming: Schema.Boolean,
3241 createdAt: IsoDateTime,
3342 updatedAt: IsoDateTime,
apps/server/src/persistence/Services/ProjectionThreads.ts+7−0
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
1010 IsoDateTime,
1111 ModelSelection,
1212 NonNegativeInt,
13+ OrchestrationThreadNotificationSummary,
1314 ProjectId,
1415 ProviderInteractionMode,
1516 RuntimeMode,
@@ -44,6 +45,12 @@ export const ProjectionThread = Schema.Struct({ · 28 unmodified lines
4445 pendingApprovalCount: NonNegativeInt,
4546 pendingUserInputCount: NonNegativeInt,
4647 hasActionableProposedPlan: NonNegativeInt,
48+ /**
49+ * Newest proactive delivery on this thread, summarized so shell readers do
50+ * not have to load messages. Refreshed alongside the other shell-summary
51+ * columns; `null` on every thread that has never received a delivery.
52+ */
53+ latestNotification: Schema.NullOr(OrchestrationThreadNotificationSummary),
4754 deletedAt: Schema.NullOr(IsoDateTime),
4855 });
4956 export type ProjectionThread = typeof ProjectionThread.Type;
apps/server/src/project/ProjectSetupScriptRunner.test.ts+1−0
@@ -44,6 +44,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => · 43 unmodified lines
4444 getThreadCheckpointContext: () => Effect.die("unused"),
4545 getFullThreadDiffContext: () => Effect.die("unused"),
4646 getThreadShellById: () => Effect.die("unused"),
47+ getThreadArchiveStateById: () => Effect.die("unused"),
4748 getThreadDetailById: () => Effect.die("unused"),
4849 getThreadDetailSnapshot: () => Effect.die("unused"),
4950 });
apps/server/src/provider/Drivers/HermesDriver.ts+20−1
@@ -4,13 +4,17 @@ import { · 3 unmodified lines
44 HermesSettings,
55 TextGenerationError,
66 type ServerProvider,
7+ type ServerSettings,
78 } from "@t3tools/contracts";
89 import * as Crypto from "effect/Crypto";
910 import * as DateTime from "effect/DateTime";
1011 import * as Effect from "effect/Effect";
12+import * as Option from "effect/Option";
1113 import * as Schema from "effect/Schema";
1214 import * as Stream from "effect/Stream";
1315
16+import { readHomeThreadId } from "../../orchestration/homeThreads.ts";
17+import { ServerSettingsService } from "../../serverSettings.ts";
1418 import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts";
1519 import { makeHermesAdapter } from "../Layers/HermesAdapter.ts";
1620 import {
@@ -23,7 +27,7 @@ import { HermesGatewayBroker } from "../Services/HermesGatewayBroker.ts"; · 6 unmodified lines
2327
2428 const decodeHermesSettings = Schema.decodeSync(HermesSettings);
2529
26−export type HermesDriverEnv = Crypto.Crypto;
30+export type HermesDriverEnv = Crypto.Crypto | ServerSettingsService;
2731
2832 const unsupportedTextGeneration = (
2933 operation:
@@ -57,6 +61,9 @@ export const HermesDriver: ProviderDriver<HermesSettings, HermesDriverEnv> = { · 27 unmodified lines
5761 create: ({ instanceId, displayName, accentColor, enabled }) =>
5862 Effect.gen(function* () {
5963 const broker = yield* HermesGatewayBroker;
64+ // Captured once at construction: `getSnapshot` must be context-free
65+ // (`R = never`) because the registry calls it outside this scope.
66+ const settings = yield* ServerSettingsService;
6067 const adapter = yield* makeHermesAdapter({ instanceId });
6168 const continuationIdentity = defaultProviderContinuationIdentity({
6269 driverKind: HERMES_DRIVER_KIND,
@@ -71,7 +78,19 @@ export const HermesDriver: ProviderDriver<HermesSettings, HermesDriverEnv> = { · 8 unmodified lines
7178 const status = yield* broker
7279 .getInstanceStatus(instanceId)
7380 .pipe(Effect.catchTag("HermesGatewayManagementError", () => Effect.succeed(undefined)));
81+ // Read-only: a snapshot must never create the thread as a side effect
82+ // (this runs on every status tick). The handshake owns creation.
83+ // A settings read that fails degrades to "no designation" rather than
84+ // failing the whole snapshot — the pin is cosmetic, the status is not.
85+ const currentSettings = yield* settings.getSettings.pipe(
86+ Effect.map(Option.some),
87+ Effect.orElseSucceed(() => Option.none<ServerSettings>()),
88+ );
89+ const homeThreadId = Option.isSome(currentSettings)
90+ ? readHomeThreadId(currentSettings.value.providerInstances[instanceId])
91+ : undefined;
7492 return {
93+ ...(homeThreadId !== undefined ? { homeThreadId } : {}),
7594 instanceId,
7695 driver: HERMES_DRIVER_KIND,
7796 ...(displayName ? { displayName } : {}),
apps/server/src/provider/Layers/HermesGatewayBroker.test.ts+100−4
@@ -39,6 +39,13 @@ import { · 38 unmodified lines
3939 makeHermesGatewayBroker,
4040 } from "./HermesGatewayBroker.ts";
4141
42+/**
43+ * A version this server does not speak. Expressed relative to the supported
44+ * version rather than as a literal, so bumping the protocol does not turn
45+ * these "must be rejected" cases into "is the current version" cases.
46+ */
47+const UNSUPPORTED_PROTOCOL_VERSION = HERMES_GATEWAY_PROTOCOL_VERSION + 1;
48+
4249 const instanceId = ProviderInstanceId.make("hermes_remote");
4350 const otherInstanceId = ProviderInstanceId.make("hermes_other");
4451 const defaultHermesInstanceId = ProviderInstanceId.make("hermes");
@@ -94,7 +101,10 @@ const testLayer = Layer.mergeAll( · 49 unmodified lines
94101 const hello = (
95102 authentication: HermesGatewayConnectionHello["authentication"],
96103 protocolVersion: number = HERMES_GATEWAY_PROTOCOL_VERSION,
97− overrides: { readonly model?: string } = {},
104+ overrides: {
105+ readonly model?: string;
106+ readonly role?: HermesGatewayConnectionHello["role"];
107+ } = {},
98108 ): HermesGatewayConnectionHello => ({
99109 type: "connection.hello",
100110 requestId: HermesGatewayRequestId.make(`hello-${protocolVersion}`),
@@ -103,6 +113,7 @@ const hello = ( · 2 unmodified lines
103113 hermesVersion: "1.0.0",
104114 capabilities: { ...capabilities, protocolVersion },
105115 authentication,
116+ role: overrides.role ?? "gateway",
106117 ...(overrides.model !== undefined ? { model: overrides.model } : {}),
107118 });
108119
@@ -174,7 +185,7 @@ it.effect("authenticates before applying incompatible connection state", () => · 65 unmodified lines
174185 instanceId,
175186 credential: HermesGatewayCredential.make("not-the-real-credential"),
176187 },
177− 3,
188+ UNSUPPORTED_PROTOCOL_VERSION,
178189 ),
179190 second.transport,
180191 ),
@@ -202,7 +213,7 @@ it.effect("authenticates before applying incompatible connection state", () => · 21 unmodified lines
202213 instanceId,
203214 credential: registered.accepted.credential!,
204215 },
205− 3,
216+ UNSUPPORTED_PROTOCOL_VERSION,
206217 ),
207218 second.transport,
208219 ),
@@ -214,7 +225,10 @@ it.effect("authenticates before applying incompatible connection state", () => · 5 unmodified lines
214225 const otherEnrollment = yield* enroll(broker, otherInstanceId, "Other Hermes");
215226 const incompatibleEnrollment = yield* Effect.flip(
216227 broker.registerConnection(
217− hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }, 3),
228+ hello(
229+ { type: "enrollment-token", token: otherEnrollment.oneTimeToken },
230+ UNSUPPORTED_PROTOCOL_VERSION,
231+ ),
218232 second.transport,
219233 ),
220234 );
@@ -1226,3 +1240,85 @@ it.effect("migrates a revoked legacy instance and drops its credential", () => · 1005 unmodified lines
12261240 assert.equal(reconnect.code, "invalid-authentication");
12271241 }).pipe(Effect.provide(testLayer)),
12281242 );
1243+
1244+it.effect("a delivery connection never displaces the live gateway connection", () =>
1245+ Effect.gen(function* () {
1246+ // The failure this guards: an out-of-process cron run dials in to hand
1247+ // over one delivery, `registerConnection` treats it as a replacement, and
1248+ // the real plugin gets closed with 4001 — knocking Hermes offline for the
1249+ // duration of a cron job.
1250+ const broker = yield* makeBroker(makeSecretStore());
1251+ const enrollment = yield* enroll(broker, instanceId, "Remote Hermes");
1252+ const live = recordingTransport();
1253+ const gateway = yield* broker.registerConnection(
1254+ hello({ type: "enrollment-token", token: enrollment.oneTimeToken }),
1255+ live.transport,
1256+ );
1257+ assert.equal(gateway.role, "gateway");
1258+ assert.isTrue(yield* broker.isConnected(instanceId));
1259+
1260+ const credential = gateway.accepted.credential!;
1261+ const cron = recordingTransport();
1262+ const delivery = yield* broker.registerConnection(
1263+ hello(
1264+ { type: "instance-credential", instanceId, credential },
1265+ HERMES_GATEWAY_PROTOCOL_VERSION,
1266+ { role: "delivery" },
1267+ ),
1268+ cron.transport,
1269+ );
1270+
1271+ assert.equal(delivery.role, "delivery");
1272+ // No generation: it was never registered, so there is nothing for it to
1273+ // be stale against — and a generation would let it be compared with, and
1274+ // mistaken for, the live connection.
1275+ assert.equal(delivery.generation, null);
1276+ assert.deepEqual(live.closes, [], "the live gateway socket must not be closed");
1277+ assert.isTrue(yield* broker.isConnected(instanceId));
1278+
1279+ // A delivery socket holds no session, so it must not be able to answer
1280+ // the live connection's outstanding requests — otherwise a throwaway cron
1281+ // socket could speak for the real plugin.
1282+ const threadId = ThreadId.make("delivery-fencing-thread");
1283+ const pendingRequestId = HermesGatewayRequestId.make("delivery-fencing-session");
1284+ const pending = yield* broker
1285+ .request(instanceId, {
1286+ type: "session.ensure",
1287+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
1288+ requestId: pendingRequestId,
1289+ threadId,
1290+ })
1291+ .pipe(Effect.forkChild({ startImmediately: true }));
1292+ yield* Effect.yieldNow;
1293+ yield* broker.receive(delivery, {
1294+ type: "session.ready",
1295+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
1296+ requestId: pendingRequestId,
1297+ threadId,
1298+ sessionId: HermesGatewaySessionId.make("delivery-forged-session"),
1299+ resumed: false,
1300+ });
1301+ yield* Effect.yieldNow;
1302+ assert.isUndefined(
1303+ pending.pollUnsafe(),
1304+ "a delivery connection must not complete the live connection's requests",
1305+ );
1306+
1307+ // The live connection still can.
1308+ yield* broker.receive(gateway, {
1309+ type: "session.ready",
1310+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
1311+ requestId: pendingRequestId,
1312+ threadId,
1313+ sessionId: HermesGatewaySessionId.make("real-session"),
1314+ resumed: false,
1315+ });
1316+ assert.equal((yield* Fiber.join(pending)).type, "session.ready");
1317+
1318+ // Closing the delivery socket is its normal lifecycle and must leave the
1319+ // instance's liveness untouched.
1320+ yield* broker.disconnect(delivery);
1321+ assert.isTrue(yield* broker.isConnected(instanceId));
1322+ assert.deepEqual(live.closes, []);
1323+ }).pipe(Effect.provide(testLayer)),
1324+);
apps/server/src/provider/Layers/HermesGatewayBroker.ts+38−0
@@ -965,6 +965,31 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { · 964 unmodified lines
965965 );
966966 }
967967
968+ // ── Delivery connections: authenticated, but never the primary.
969+ //
970+ // An out-of-process cron run dials in only to hand over a `home.deliver`
971+ // and leave. Registering it would displace the instance's live gateway
972+ // socket ("replaced by a newer connection") and bump the generation,
973+ // knocking the real plugin offline for the duration of a cron job. So it
974+ // is accepted and then deliberately left out of the connection registry:
975+ // no generation, no liveness ping, no status publish, nothing to
976+ // disconnect. The existing primary is untouched.
977+ if (hello.role === "delivery") {
978+ return {
979+ instanceId,
980+ generation: null,
981+ role: "delivery",
982+ accepted: {
983+ type: "connection.accepted",
984+ requestId: hello.requestId,
985+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
986+ instanceId,
987+ nickname: record.nickname,
988+ ...(credential ? { credential } : {}),
989+ },
990+ } as const satisfies HermesGatewayConnectionRegistration;
991+ }
992+
968993 const observed: HermesObservedConnection = {
969994 pluginVersion: hello.pluginVersion,
970995 hermesVersion: hello.hermesVersion,
@@ -986,6 +1011,7 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { · 15 unmodified lines
9861011 const registration = {
9871012 instanceId,
9881013 generation: accepted.generation,
1014+ role: "gateway",
9891015 accepted: {
9901016 type: "connection.accepted",
9911017 requestId: hello.requestId,
@@ -1009,6 +1035,14 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { · 17 unmodified lines
10091035
10101036 const receive = (registration: HermesGatewayConnectionRegistration, message: PluginMessage) =>
10111037 Effect.gen(function* () {
1038+ // A delivery connection's one frame — `home.deliver` — is handled at the
1039+ // transport layer, because its ack has to go back to that specific
1040+ // short-lived socket. Anything else it sends is outside its remit and is
1041+ // dropped here: it holds no session, so completing a correlated request
1042+ // or publishing a status off it would let a throwaway cron socket speak
1043+ // for the live connection.
1044+ if (registration.role === "delivery") return;
1045+
10121046 if (message.type === "connection.status") {
10131047 // Generation-fenced inside the Ref update; a stale registration is a
10141048 // no-op rather than an overwrite.
@@ -1031,6 +1065,10 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { · 16 unmodified lines
10311065
10321066 const disconnect = (registration: HermesGatewayConnectionRegistration) =>
10331067 Effect.gen(function* () {
1068+ // Delivery sockets close as a matter of course — that is their whole
1069+ // lifecycle. They were never in the registry, so retiring one must not
1070+ // touch the instance's liveness or fail the live connection's requests.
1071+ if (registration.role === "delivery") return;
10341072 const retired = yield* connections.disconnect(registration);
10351073 if (!retired) return;
10361074 yield* publishCurrentStatus(registration.instanceId, "get-status");
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1−0
@@ -205,6 +205,7 @@ describe("ProviderSessionReaper", () => { · 204 unmodified lines
205205 getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
206206 getThreadCheckpointContext: () => Effect.die("unused"),
207207 getFullThreadDiffContext: () => Effect.die("unused"),
208+ getThreadArchiveStateById: () => Effect.succeed(Option.none()),
208209 getThreadShellById: (threadId) =>
209210 Effect.succeed(
210211 input.readModel.threads.find((thread) => thread.id === threadId)
apps/server/src/provider/Services/HermesGatewayBroker.ts+17−1
@@ -1,5 +1,6 @@
11 import type {
22 HermesGatewayConnectionHello,
3+ HermesGatewayConnectionRole,
34 HermesGatewayCreateEnrollmentInput,
45 HermesGatewayEnrollmentResult,
56 HermesGatewayInstanceStatus,
@@ -27,7 +28,22 @@ export interface HermesGatewayTransport { · 21 unmodified lines
2728
2829 export interface HermesGatewayConnectionRegistration {
2930 readonly instanceId: ProviderInstanceId;
30− readonly generation: number;
31+ /**
32+ * Fencing token for the instance's primary connection, or `null` for a
33+ * delivery connection.
34+ *
35+ * A delivery connection is deliberately outside the generation scheme: it
36+ * was never registered as the primary, so there is nothing for it to be
37+ * stale against, and giving it a generation would let it be compared —
38+ * and therefore mistaken for a replacement — against the live socket.
39+ */
40+ readonly generation: number | null;
41+ /**
42+ * What this socket registered as. `delivery` sockets may only hand over
43+ * `home.deliver` frames; they carry no session, take no ping loop, and
44+ * their disconnect must not disturb the instance's liveness state.
45+ */
46+ readonly role: HermesGatewayConnectionRole;
3147 readonly accepted: Extract<
3248 HermesGatewayT3ToPluginMessage,
3349 { readonly type: "connection.accepted" }
apps/server/src/provider/hermesGatewayHttp.ts+146−1
@@ -1,8 +1,14 @@
11 import {
2+ CommandId,
3+ HERMES_GATEWAY_PROTOCOL_VERSION,
24 HermesGatewayConnectionHello,
35 HermesGatewayPluginToT3Message,
46 HermesGatewayT3ToPluginMessage,
7+ MessageId,
8+ type ThreadId,
59 } from "@t3tools/contracts";
10+import * as Cause from "effect/Cause";
11+import * as Crypto from "effect/Crypto";
612 import * as Effect from "effect/Effect";
713 import * as Layer from "effect/Layer";
814 import * as Option from "effect/Option";
@@ -11,6 +17,9 @@ import * as Schema from "effect/Schema"; · 2 unmodified lines
1117 import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
1218 import * as Socket from "effect/unstable/socket/Socket";
1319
20+import { getOrCreateHomeThread } from "../orchestration/homeThreads.ts";
21+import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";
22+import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
1423 import {
1524 HermesGatewayBroker,
1625 type HermesGatewayConnectionRegistration,
@@ -28,6 +37,107 @@ const isConnectionHello = Schema.is(HermesGatewayConnectionHello); · 11 unmodified lines
2837 export const hermesGatewayWebSocketRouteLayer = Layer.unwrap(
2938 Effect.gen(function* () {
3039 const broker = yield* HermesGatewayBroker;
40+ const engine = yield* OrchestrationEngineService;
41+ const query = yield* ProjectionSnapshotQuery;
42+ const crypto = yield* Crypto.Crypto;
43+
44+ /**
45+ * Write one proactive delivery into the instance's home thread and ack it.
46+ *
47+ * The ack is sent **only after the dispatch succeeds**. The plugin purges
48+ * its queued copy on the ack and on nothing else, so acking optimistically
49+ * would silently drop deliveries whenever a write failed. An unacked
50+ * delivery is retried on the next connect and deduped there by
51+ * `deliveryId`, which makes the pessimistic order the safe one.
52+ *
53+ * Accepted from either connection role, and deliberately not generation-
54+ * fenced: a delivery carries no session and cannot conflict with the live
55+ * connection's turns, and fencing it would reject exactly the out-of-
56+ * process cron case this exists to serve.
57+ */
58+ const deliverHomeNotification = (
59+ registration: HermesGatewayConnectionRegistration,
60+ message: Extract<HermesGatewayPluginToT3Message, { readonly type: "home.deliver" }>,
61+ transport: {
62+ readonly send: (
63+ frame: HermesGatewayT3ToPluginMessage,
64+ ) => Effect.Effect<void, ProviderAdapterRequestError>;
65+ },
66+ ) =>
67+ Effect.gen(function* () {
68+ // Re-resolve rather than trusting the plugin's threadId: its cached
69+ // T3_HOME_CHANNEL may name a thread from a previous designation, and a
70+ // delivery must never land in an arbitrary caller-named thread.
71+ const homeThreadId = yield* getOrCreateHomeThread({
72+ instanceId: registration.instanceId,
73+ title: registration.accepted.nickname,
74+ });
75+
76+ // Dedupe here, against the projection, before dispatching.
77+ //
78+ // The decider also dedupes, but it can only see the in-memory command
79+ // read model — which is built with `messages: []` on every thread
80+ // (`getCommandReadModel`, deliberately, to avoid hydrating message
81+ // bodies at boot). Its scan therefore matches nothing, and a replay
82+ // after a server restart appends a second copy. This read hits
83+ // `projection_thread_messages`, where the delivery actually lives, so
84+ // it is the check that holds across a restart — exactly the case the
85+ // plugin's durable queue exists to produce.
86+ const alreadyDelivered = (threadId: ThreadId) =>
87+ query.getThreadDetailById(threadId).pipe(
88+ Effect.map(
89+ Option.match({
90+ onNone: () => false,
91+ onSome: (thread) =>
92+ thread.messages.some(
93+ (candidate) => candidate.notification?.deliveryId === message.deliveryId,
94+ ),
95+ }),
96+ ),
97+ Effect.orElseSucceed(() => false),
98+ );
99+
100+ if (!(yield* alreadyDelivered(homeThreadId))) {
101+ const dispatched = yield* Effect.result(
102+ engine.dispatch({
103+ type: "thread.notification.deliver",
104+ commandId: CommandId.make(yield* crypto.randomUUIDv4),
105+ threadId: homeThreadId,
106+ messageId: MessageId.make(yield* crypto.randomUUIDv4),
107+ deliveryId: message.deliveryId,
108+ kind: message.kind,
109+ label: message.label,
110+ text: message.text,
111+ createdAt: message.createdAt,
112+ }),
113+ );
114+
115+ // A dispatch failure is not proof the delivery is absent — a racing
116+ // socket may have won between the read above and this write. Re-read
117+ // before giving up, or that delivery goes unacked forever and the
118+ // plugin replays it on every single reconnect.
119+ if (dispatched._tag === "Failure" && !(yield* alreadyDelivered(homeThreadId))) {
120+ return yield* Effect.fail(dispatched.failure);
121+ }
122+ }
123+
124+ yield* transport.send({
125+ type: "home.deliver.ack",
126+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
127+ deliveryId: message.deliveryId,
128+ });
129+ }).pipe(
130+ Effect.catchCause((cause) =>
131+ // Deliberately no ack: the plugin keeps the delivery queued and
132+ // retries it on the next connect.
133+ Effect.logWarning("Hermes home delivery failed", {
134+ instanceId: registration.instanceId,
135+ deliveryId: message.deliveryId,
136+ cause: Cause.pretty(cause),
137+ }),
138+ ),
139+ );
140+
31141 return HttpRouter.add(
32142 "GET",
33143 HERMES_GATEWAY_WEBSOCKET_PATH,
@@ -76,7 +186,31 @@ export const hermesGatewayWebSocketRouteLayer = Layer.unwrap( · 42 unmodified lines
76186 ),
77187 );
78188 yield* Ref.set(registration, Option.some(registered));
79− yield* transport.send(registered.accepted);
189+
190+ // Resolve the home thread here rather than inside
191+ // `registerConnection`, whose error channel is exactly
192+ // `connection.rejected`: a thread-creation failure is not a
193+ // reason to refuse an authenticated plugin. On failure the
194+ // handshake completes without `homeThreadId`, the plugin keeps
195+ // whatever designation it already had, and the next connect
196+ // converges — which is the whole point of converge-on-read.
197+ const homeThreadId = yield* getOrCreateHomeThread({
198+ instanceId: registered.instanceId,
199+ title: registered.accepted.nickname,
200+ }).pipe(
201+ Effect.map(Option.some),
202+ Effect.catchCause((cause) =>
203+ Effect.logWarning("home thread resolution failed", {
204+ instanceId: registered.instanceId,
205+ cause: Cause.pretty(cause),
206+ }).pipe(Effect.as(Option.none<ThreadId>())),
207+ ),
208+ );
209+
210+ yield* transport.send({
211+ ...registered.accepted,
212+ ...(Option.isSome(homeThreadId) ? { homeThreadId: homeThreadId.value } : {}),
213+ });
80214 return;
81215 }
82216
@@ -84,6 +218,17 @@ export const hermesGatewayWebSocketRouteLayer = Layer.unwrap( · 1 unmodified line
84218 yield* transport.close(4002, "connection.hello may only be sent once");
85219 return;
86220 }
221+
222+ // Deliveries are handled on the socket that carried them rather
223+ // than through the broker's event stream, because the ack has to
224+ // go back to *this* connection — which for an out-of-process cron
225+ // run is a short-lived delivery socket that no stream subscriber
226+ // can address.
227+ if (message.type === "home.deliver") {
228+ yield* deliverHomeNotification(current.value, message, transport);
229+ return;
230+ }
231+
87232 yield* broker.receive(current.value, message);
88233 }).pipe(
89234 Effect.catch((cause) =>
apps/server/src/relay/AgentAwarenessRelay.test.ts+24−0
@@ -201,6 +201,30 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { · 200 unmodified lines
201201 },
202202 } as unknown as OrchestrationEvent),
203203 ).toBe(false);
204+ // A cron result or agent-initiated message is exactly the news push
205+ // exists for; a lifecycle notice appends quietly and must never ring.
206+ for (const kind of ["cron", "message", "handoff", "other"] as const) {
207+ expect(
208+ AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({
209+ ...base,
210+ type: "thread.notification-delivered",
211+ payload: {
212+ threadId: "thread-1" as ThreadId,
213+ notification: { kind, label: "Cron: daily-digest", deliveryId: "delivery-1" },
214+ },
215+ } as unknown as OrchestrationEvent),
216+ ).toBe(true);
217+ }
218+ expect(
219+ AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({
220+ ...base,
221+ type: "thread.notification-delivered",
222+ payload: {
223+ threadId: "thread-1" as ThreadId,
224+ notification: { kind: "lifecycle", label: "Agent restarted", deliveryId: "delivery-2" },
225+ },
226+ } as unknown as OrchestrationEvent),
227+ ).toBe(false);
204228 });
205229
206230 it("deduplicates awareness state updates whose only change is their event timestamp", () => {
apps/server/src/relay/AgentAwarenessRelay.ts+5−0
@@ -78,6 +78,11 @@ export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boo · 77 unmodified lines
7878 case "thread.runtime-mode-set":
7979 case "thread.interaction-mode-set":
8080 return false;
81+ case "thread.notification-delivered":
82+ // A cron result or agent-initiated message is exactly the kind of news
83+ // push exists for. A lifecycle notice ("agent restarted") is not: it
84+ // appends quietly and must never ring a device.
85+ return event.payload.notification.kind !== "lifecycle";
8186 case "thread.activity-appended":
8287 return (
8388 event.payload.activity.kind === "approval.requested" ||
apps/server/src/serverRuntimeStartup.test.ts+4−0
@@ -96,6 +96,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa · 95 unmodified lines
9696 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
9797 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
9898 getThreadShellById: () => Effect.succeed(Option.none()),
99+ getThreadArchiveStateById: () => Effect.succeed(Option.none()),
99100 getThreadDetailById: () => Effect.succeed(Option.none()),
100101 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
101102 }),
@@ -161,6 +162,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa · 59 unmodified lines
161162 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
162163 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
163164 getThreadShellById: () => Effect.die("unused"),
165+ getThreadArchiveStateById: () => Effect.die("unused"),
164166 getThreadDetailById: () => Effect.die("unused"),
165167 getThreadDetailSnapshot: () => Effect.die("unused"),
166168 }),
@@ -206,6 +208,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when · 39 unmodified lines
206208 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
207209 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
208210 getThreadShellById: () => Effect.die("unused"),
211+ getThreadArchiveStateById: () => Effect.die("unused"),
209212 getThreadDetailById: () => Effect.die("unused"),
210213 getThreadDetailSnapshot: () => Effect.die("unused"),
211214 }),
@@ -257,6 +260,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa · 45 unmodified lines
257260 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
258261 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
259262 getThreadShellById: () => Effect.die("unused"),
263+ getThreadArchiveStateById: () => Effect.die("unused"),
260264 getThreadDetailById: () => Effect.die("unused"),
261265 getThreadDetailSnapshot: () => Effect.die("unused"),
262266 }),
apps/server/src/ws.ts+22−0
@@ -73,6 +73,7 @@ import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; · 72 unmodified lines
7373 import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
7474 import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
7575 import { getOrCreateAgentProject } from "./orchestration/agentProjects.ts";
76+import { isHomeThread } from "./orchestration/homeThreads.ts";
7677 import {
7778 observeRpcEffect as instrumentRpcEffect,
7879 observeRpcStream as instrumentRpcStream,
@@ -262,6 +263,7 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< · 183 unmodified lines
262263 {
263264 type:
264265 | "thread.message-sent"
266+ | "thread.notification-delivered"
265267 | "thread.proposed-plan-upserted"
266268 | "thread.activity-appended"
267269 | "thread.turn-diff-completed"
@@ -271,6 +273,9 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< · 3 unmodified lines
271273 > {
272274 return (
273275 event.type === "thread.message-sent" ||
276+ // A proactive delivery appends a transcript message, so an open detail
277+ // subscription has to receive it or the row only appears on reload.
278+ event.type === "thread.notification-delivered" ||
274279 event.type === "thread.proposed-plan-upserted" ||
275280 event.type === "thread.activity-appended" ||
276281 event.type === "thread.turn-diff-completed" ||
@@ -1124,6 +1129,23 @@ const makeWsRpcLayer = ( · 847 unmodified lines
11241129 ORCHESTRATION_WS_METHODS.dispatchCommand,
11251130 Effect.gen(function* () {
11261131 const normalizedCommand = yield* normalizeDispatchCommand(command);
1132+
1133+ // A home thread is a fixed designation, not a movable one:
1134+ // deleting it would leave its agent with nowhere to deliver, and
1135+ // the next handshake would silently mint a replacement, orphaning
1136+ // the history in the old one. This is the real guard — the UI
1137+ // only declines to offer the action.
1138+ if (
1139+ normalizedCommand.type === "thread.delete" &&
1140+ (yield* isHomeThread(normalizedCommand.threadId))
1141+ ) {
1142+ return yield* Effect.fail(
1143+ new OrchestrationDispatchCommandError({
1144+ message: "This agent's Home thread cannot be deleted.",
1145+ }),
1146+ );
1147+ }
1148+
11271149 const shouldStopSessionAfterArchive =
11281150 normalizedCommand.type === "thread.archive"
11291151 ? yield* projectionSnapshotQuery
apps/web/src/components/Sidebar.logic.test.ts+149−0
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" · 1 unmodified line
22 import {
33 archiveSelectedThreadEntries,
44 buildAgentSidebarEntries,
5+ buildHomeThreadIdByProjectKey,
56 buildMultiSelectThreadContextMenuItems,
67 createThreadJumpHintVisibilityController,
78 formatAgentSidebarLabel,
@@ -42,6 +43,7 @@ import { · 34 unmodified lines
4243 ThreadId,
4344 } from "@t3tools/contracts";
4445
46+import { sortThreads, type ThreadSortInput } from "../lib/threadSort";
4547 import {
4648 DEFAULT_INTERACTION_MODE,
4749 DEFAULT_RUNTIME_MODE,
@@ -1557,4 +1559,151 @@ describe("agents sidebar section", () => { · 1509 unmodified lines
15571559 expect(entries).toHaveLength(1);
15581560 expect(entries[0]?.instanceId).toBe(instanceId);
15591561 });
1562+
1563+ it("carries the provider's designated home thread onto the entry", () => {
1564+ const entries = buildAgentSidebarEntries({
1565+ projects: [makeAgentProject()],
1566+ providerByInstanceId: new Map([
1567+ [String(instanceId), makeServerProvider({ homeThreadId: "thread-home" as never })],
1568+ ]),
1569+ });
1570+
1571+ expect(entries[0]?.homeThreadId).toBe("thread-home");
1572+ });
1573+
1574+ it("reports no home thread for a driver that designates none", () => {
1575+ // Every non-Hermes driver, and a Hermes instance before its first
1576+ // handshake — both must read as "this list has no pinned row".
1577+ const entries = buildAgentSidebarEntries({
1578+ projects: [makeAgentProject()],
1579+ providerByInstanceId: new Map([[String(instanceId), makeServerProvider()]]),
1580+ });
1581+
1582+ expect(entries[0]?.homeThreadId).toBeNull();
1583+ });
1584+});
1585+
1586+describe("buildHomeThreadIdByProjectKey", () => {
1587+ function makeEntry(input: {
1588+ readonly instanceId: string;
1589+ readonly projectKey: string;
1590+ readonly homeThreadId: string | null;
1591+ }) {
1592+ return {
1593+ instanceId: input.instanceId as never,
1594+ project: {
1595+ agentInstanceId: input.instanceId as never,
1596+ title: input.instanceId,
1597+ displayName: input.instanceId,
1598+ projectKey: input.projectKey,
1599+ },
1600+ label: input.instanceId,
1601+ statusKey: "ready" as const,
1602+ statusLabel: "Connected",
1603+ statusPulse: false,
1604+ homeThreadId: input.homeThreadId as never,
1605+ };
1606+ }
1607+
1608+ it("maps each agent's project key to its home thread", () => {
1609+ const map = buildHomeThreadIdByProjectKey([
1610+ makeEntry({ instanceId: "hermes-a", projectKey: "project-a", homeThreadId: "thread-home-a" }),
1611+ makeEntry({ instanceId: "hermes-b", projectKey: "project-b", homeThreadId: "thread-home-b" }),
1612+ ]);
1613+
1614+ expect(map.get("project-a")).toBe("thread-home-a");
1615+ expect(map.get("project-b")).toBe("thread-home-b");
1616+ });
1617+
1618+ it("omits agents with no designation", () => {
1619+ const map = buildHomeThreadIdByProjectKey([
1620+ makeEntry({ instanceId: "hermes-a", projectKey: "project-a", homeThreadId: null }),
1621+ ]);
1622+
1623+ expect(map.size).toBe(0);
1624+ });
1625+
1626+ it("keeps the first entry when two agents collapse onto one project key", () => {
1627+ // `visibleSidebarSections` de-duplicates by `projectKey` and keeps the
1628+ // first section, so the pin must resolve to the same one.
1629+ const map = buildHomeThreadIdByProjectKey([
1630+ makeEntry({ instanceId: "hermes-a", projectKey: "shared", homeThreadId: "thread-home-a" }),
1631+ makeEntry({ instanceId: "hermes-b", projectKey: "shared", homeThreadId: "thread-home-b" }),
1632+ ]);
1633+
1634+ expect(map.get("shared")).toBe("thread-home-a");
1635+ });
1636+});
1637+
1638+describe("home thread pinning across both sidebar sort sites", () => {
1639+ // `SidebarProjectItem` (the rendered rows) and `visibleSidebarThreadKeys`
1640+ // (jump shortcuts, prev/next, prewarming) each call `sortThreads` with the
1641+ // same pinned id. These assert the shared helper produces one ordering, and
1642+ // that the ordering survives the prewarm slice.
1643+ function makeSortableThread(input: {
1644+ readonly id: string;
1645+ readonly updatedAt: string;
1646+ }): { id: string } & ThreadSortInput {
1647+ return {
1648+ id: input.id,
1649+ createdAt: input.updatedAt,
1650+ updatedAt: input.updatedAt,
1651+ latestUserMessageAt: input.updatedAt,
1652+ messages: [],
1653+ };
1654+ }
1655+
1656+ const threads = [
1657+ makeSortableThread({ id: "thread-newest", updatedAt: "2026-03-09T12:00:00.000Z" }),
1658+ makeSortableThread({ id: "thread-middle", updatedAt: "2026-03-09T10:00:00.000Z" }),
1659+ // Deliberately the oldest row in the list: a home thread whose last cron
1660+ // ran a month ago must still hold position one.
1661+ makeSortableThread({ id: "thread-home", updatedAt: "2026-01-01T00:00:00.000Z" }),
1662+ ];
1663+
1664+ it("sorts the home thread first even when it is the oldest", () => {
1665+ expect(sortThreads(threads, "updated_at", "thread-home").map((thread) => thread.id)).toEqual([
1666+ "thread-home",
1667+ "thread-newest",
1668+ "thread-middle",
1669+ ]);
1670+ });
1671+
1672+ it("produces an identical ordering at both sort sites", () => {
1673+ const renderOrder = sortThreads(threads, "updated_at", "thread-home");
1674+ const traversalOrder = sortThreads(threads, "updated_at", "thread-home");
1675+
1676+ expect(renderOrder.map((thread) => thread.id)).toEqual(
1677+ traversalOrder.map((thread) => thread.id),
1678+ );
1679+ });
1680+
1681+ it("leaves a provider list with no designation unchanged", () => {
1682+ // Non-Hermes drivers and pre-handshake Hermes instances both land here.
1683+ expect(sortThreads(threads, "updated_at", null).map((thread) => thread.id)).toEqual([
1684+ "thread-newest",
1685+ "thread-middle",
1686+ "thread-home",
1687+ ]);
1688+ expect(sortThreads(threads, "updated_at").map((thread) => thread.id)).toEqual([
1689+ "thread-newest",
1690+ "thread-middle",
1691+ "thread-home",
1692+ ]);
1693+ });
1694+
1695+ it("ignores a pinned id that matches no thread", () => {
1696+ expect(sortThreads(threads, "updated_at", "thread-gone").map((thread) => thread.id)).toEqual([
1697+ "thread-newest",
1698+ "thread-middle",
1699+ "thread-home",
1700+ ]);
1701+ });
1702+
1703+ it("puts the home thread in the prewarm slice even when the limit is one", () => {
1704+ // Pinning happens before the prewarm slice, so Home is always warm.
1705+ const ordered = sortThreads(threads, "updated_at", "thread-home").map((thread) => thread.id);
1706+
1707+ expect(getSidebarThreadIdsToPrewarm(ordered, 1)).toEqual(["thread-home"]);
1708+ });
15601709 });
apps/web/src/components/Sidebar.logic.ts+42−1
@@ -1,5 +1,10 @@
11 import * as React from "react";
2−import type { ContextMenuItem, ProviderInstanceId, ServerProvider } from "@t3tools/contracts";
2+import type {
3+ ContextMenuItem,
4+ ProviderInstanceId,
5+ ServerProvider,
6+ ThreadId,
7+} from "@t3tools/contracts";
38 import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
49 import type { ProviderStatusKey } from "./settings/providerStatus";
510 import {
@@ -879,6 +884,39 @@ export interface AgentSidebarEntry<TProject extends AgentSidebarProjectInput> { · 873 unmodified lines
879884 readonly statusLabel: string;
880885 /** True while the instance is mid-handshake, so the dot can pulse. */
881886 readonly statusPulse: boolean;
887+ /**
888+ * The instance's home thread, pinned first in its thread list and withheld
889+ * from Delete.
890+ *
891+ * `null` for every driver that designates none, and for a Hermes instance
892+ * whose first handshake has not landed — both mean "this list has no pinned
893+ * row", which is the same rendering.
894+ */
895+ readonly homeThreadId: ThreadId | null;
896+}
897+
898+/**
899+ * Home thread per sidebar section key.
900+ *
901+ * The two thread-ordering sites (the rendered project row and the flat
902+ * `visibleSidebarThreadKeys` used by jump shortcuts, prev/next, and
903+ * prewarming) both need this, and they MUST agree — a section whose visual
904+ * order differs from the traversal order desyncs the keyboard from the eye.
905+ * One map derived once is what keeps them honest.
906+ */
907+export function buildHomeThreadIdByProjectKey<
908+ TProject extends AgentSidebarProjectInput & { readonly projectKey: string },
909+>(entries: ReadonlyArray<AgentSidebarEntry<TProject>>): ReadonlyMap<string, ThreadId> {
910+ const byProjectKey = new Map<string, ThreadId>();
911+ for (const entry of entries) {
912+ if (entry.homeThreadId === null) continue;
913+ // First writer wins: `visibleSidebarSections` de-duplicates by
914+ // `projectKey`, keeping the first section for a key, so the pin has to
915+ // resolve to that same one.
916+ if (byProjectKey.has(entry.project.projectKey)) continue;
917+ byProjectKey.set(entry.project.projectKey, entry.homeThreadId);
918+ }
919+ return byProjectKey;
882920 }
883921
884922 const AGENT_STATUS_LABELS: Record<ProviderStatusKey, string> = {
@@ -960,6 +998,9 @@ export function buildAgentSidebarEntries<TProject extends AgentSidebarProjectInp · 75 unmodified lines
960998 statusKey,
961999 statusLabel: AGENT_STATUS_LABELS[statusKey],
9621000 statusPulse: statusKey === "warning" && provider?.auth.status === "unauthenticated",
1001+ // Re-emitted on every provider status tick, so a designation made after
1002+ // the sidebar mounted pins on the next push without a refetch.
1003+ homeThreadId: provider?.homeThreadId ?? null,
9631004 });
9641005 }
9651006
apps/web/src/components/Sidebar.tsx+58−2
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 ContainerIcon,
77 FolderPlusIcon,
88 Globe2Icon,
9+ HouseIcon,
910 LoaderIcon,
1011 SearchIcon,
1112 SquarePenIcon,
@@ -174,6 +175,7 @@ import { openCommandPalette } from "../commandPaletteBus"; · 162 unmodified lines
174175 import {
175176 archiveSelectedThreadEntries,
176177 buildAgentSidebarEntries,
178+ buildHomeThreadIdByProjectKey,
177179 buildMultiSelectThreadContextMenuItems,
178180 type AgentSidebarEntry,
179181 getSidebarThreadIdsToPrewarm,
@@ -312,6 +314,11 @@ interface SidebarThreadRowProps { · 132 unmodified lines
312314 projectCwd: string | null;
313315 orderedProjectThreadKeys: readonly string[];
314316 isActive: boolean;
317+ /**
318+ * The agent's home thread. Pinned first and marked with a small leading
319+ * icon; the title stays the plain thread title ("Home"), no emoji.
320+ */
321+ isHomeThread: boolean;
315322 jumpLabel: string | null;
316323 appSettingsConfirmThreadArchive: boolean;
317324 renamingThreadKey: string | null;
@@ -349,6 +356,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr · 31 unmodified lines
349356 const {
350357 orderedProjectThreadKeys,
351358 isActive,
359+ isHomeThread,
352360 jumpLabel,
353361 appSettingsConfirmThreadArchive,
354362 renamingThreadKey,
@@ -705,6 +713,28 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr · 350 unmodified lines
705713 </Tooltip>
706714 )}
707715 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
716+ {/* Restrained: one muted leading glyph, no emoji and no badge. The
717+ row is a normal thread in every other respect — the mark only
718+ explains why it never moves out of first position. */}
719+ {isHomeThread && (
720+ <Tooltip>
721+ <TooltipTrigger
722+ render={
723+ <span
724+ role="img"
725+ aria-label="Home thread"
726+ data-testid={`thread-home-marker-${thread.id}`}
727+ className="inline-flex shrink-0 items-center justify-center text-sidebar-muted-foreground/70"
728+ />
729+ }
730+ >
731+ <HouseIcon className="size-3" />
732+ </TooltipTrigger>
733+ <TooltipPopup side="top">
734+ Home thread — this agent&rsquo;s proactive messages land here
735+ </TooltipPopup>
736+ </Tooltip>
737+ )}
708738 {renamingThreadKey === threadKey ? (
709739 <input
710740 ref={handleRenameInputRef}
@@ -890,6 +920,8 @@ interface SidebarProjectThreadListProps { · 179 unmodified lines
890920 hiddenThreadStatus: ThreadStatusPill | null;
891921 orderedProjectThreadKeys: readonly string[];
892922 renderedThreads: readonly SidebarThreadSummary[];
923+ /** Agent's home thread, or `null` for a workspace row. */
924+ homeThreadId: ThreadId | null;
893925 showEmptyThreadState: boolean;
894926 shouldShowThreadPanel: boolean;
895927 isThreadListExpanded: boolean;
@@ -941,6 +973,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( · 45 unmodified lines
941973 hiddenThreadStatus,
942974 orderedProjectThreadKeys,
943975 renderedThreads,
976+ homeThreadId,
944977 showEmptyThreadState,
945978 shouldShowThreadPanel,
946979 isThreadListExpanded,
@@ -998,6 +1031,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( · 51 unmodified lines
9981031 projectCwd={projectCwd}
9991032 orderedProjectThreadKeys={orderedProjectThreadKeys}
10001033 isActive={activeRouteThreadKey === threadKey}
1034+ isHomeThread={homeThreadId !== null && thread.id === homeThreadId}
10011035 jumpLabel={threadJumpLabelByKey.get(threadKey) ?? null}
10021036 appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive}
10031037 renamingThreadKey={renamingThreadKey}
@@ -1179,6 +1213,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 175 unmodified lines
11791213 },
11801214 });
11811215 const openPrLink = useOpenPrLink();
1216+ // The agent's designated home thread. Pinned first, and Delete is withheld
1217+ // from it (the server rejects the delete regardless — the UI just must not
1218+ // offer an action that always fails). `null` for workspace rows and for an
1219+ // agent that has not completed a handshake.
1220+ const homeThreadId = agent?.homeThreadId ?? null;
11821221 const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs);
11831222 const sidebarThreadByKey = useMemo(
11841223 () =>
@@ -1272,6 +1311,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 87 unmodified lines
12721311 const visibleProjectThreads = sortThreads(
12731312 projectThreads.filter((thread) => thread.archivedAt === null),
12741313 threadSortOrder,
1314+ homeThreadId,
12751315 );
12761316 const projectStatus = resolveProjectStatusIndicator(
12771317 visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)),
@@ -1283,7 +1323,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 5 unmodified lines
12831323 projectStatus,
12841324 visibleProjectThreads,
12851325 };
1286− }, [projectThreads, threadLastVisitedAts, threadSortOrder]);
1326+ }, [homeThreadId, projectThreads, threadLastVisitedAts, threadSortOrder]);
12871327 const pinnedCollapsedThread = useMemo(() => {
12881328 const activeThreadKey = activeRouteThreadKey ?? undefined;
12891329 if (!activeThreadKey || projectExpanded) {
@@ -2157,6 +2197,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 867 unmodified lines
21572197 );
21582198 const threadWorkspacePath =
21592199 thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null;
2200+ // The agent's home thread is undeletable — the server's decider rejects
2201+ // it, so offering the action would only ever produce an error toast.
2202+ const isHomeThread = homeThreadId !== null && thread.id === homeThreadId;
21602203 const clicked = await api.contextMenu.show(
21612204 [
21622205 ...(thread.branch
@@ -2166,7 +2209,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 3 unmodified lines
21662209 { id: "mark-unread", label: "Mark unread" },
21672210 { id: "copy-path", label: "Copy Path" },
21682211 { id: "copy-thread-id", label: "Copy Thread ID" },
2169− { id: "delete", label: "Delete", destructive: true, icon: "trash" },
2212+ ...(isHomeThread
2213+ ? []
2214+ : [{ id: "delete", label: "Delete", destructive: true, icon: "trash" } as const]),
21702215 ],
21712216 position,
21722217 );
@@ -2252,6 +2297,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 79 unmodified lines
22522297 copyThreadIdToClipboard,
22532298 deleteThread,
22542299 handleNewThread,
2300+ homeThreadId,
22552301 markThreadUnread,
22562302 memberProjectByScopedKey,
22572303 project.workspaceRoot,
@@ -2438,6 +2484,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 180 unmodified lines
24382484 hiddenThreadStatus={hiddenThreadStatus}
24392485 orderedProjectThreadKeys={orderedProjectThreadKeys}
24402486 renderedThreads={renderedThreads}
2487+ homeThreadId={homeThreadId}
24412488 showEmptyThreadState={showEmptyThreadState}
24422489 shouldShowThreadPanel={shouldShowThreadPanel}
24432490 isThreadListExpanded={isThreadListExpanded}
@@ -3571,6 +3618,13 @@ export default function Sidebar() { · 1127 unmodified lines
35713618 }
35723619 return sections;
35733620 }, [agentEntries, sortedProjects]);
3621+ // Same pin the rendered rows use (`SidebarProjectItem`), keyed the same way.
3622+ // These two orderings are the visual list and the traversal list; if they
3623+ // disagree, thread-jump numbers land on the wrong row.
3624+ const homeThreadIdByProjectKey = useMemo(
3625+ () => buildHomeThreadIdByProjectKey(agentEntries),
3626+ [agentEntries],
3627+ );
35743628 const visibleSidebarThreadKeys = useMemo(
35753629 () =>
35763630 visibleSidebarSections.flatMap((project) => {
@@ -3579,6 +3633,7 @@ export default function Sidebar() { · 2 unmodified lines
35793633 (thread) => thread.archivedAt === null,
35803634 ),
35813635 sidebarThreadSortOrder,
3636+ homeThreadIdByProjectKey.get(project.projectKey) ?? null,
35823637 );
35833638 const projectExpanded = resolveProjectExpanded(
35843639 projectExpandedById,
@@ -3609,6 +3664,7 @@ export default function Sidebar() { · 24 unmodified lines
36093664 );
36103665 }),
36113666 [
3667+ homeThreadIdByProjectKey,
36123668 sidebarThreadSortOrder,
36133669 sidebarThreadPreviewCount,
36143670 expandedThreadListsByProject,
apps/web/src/components/agents/AgentPage.logic.test.ts+101−0
@@ -1,6 +1,7 @@
11 import type {
22 ProviderInstanceDescription,
33 ProviderInstanceId,
4+ ServerProvider,
45 ServerProviderSkill,
56 } from "@t3tools/contracts";
67 import { describe, expect, it } from "vite-plus/test";
@@ -25,8 +26,10 @@ import { · 18 unmodified lines
2526 isAgentPageBusy,
2627 messageFromAgentPageError,
2728 nextExpandedSkillName,
29+ resolveAgentHomeThreadId,
2830 resolveAgentRouteInstanceId,
2931 shouldFetchAgentSkillBody,
32+ sortAgentPageThreads,
3033 supportsGatewayReconnect,
3134 upsertAgentSkillBody,
3235 type AgentConnectionStatus,
@@ -334,3 +337,101 @@ describe("resolveAgentRouteInstanceId", () => { · 301 unmodified lines
334337 expect(resolveAgentRouteInstanceId({ instanceId: " " })).toBe(null);
335338 });
336339 });
340+
341+describe("agent thread list", () => {
342+ function makeProvider(overrides: Partial<ServerProvider> = {}): ServerProvider {
343+ return {
344+ instanceId: "hermes-main" as ProviderInstanceId,
345+ driver: "hermes",
346+ displayName: "Hermes · Main",
347+ enabled: true,
348+ installed: true,
349+ version: null,
350+ status: "ready",
351+ auth: { status: "authenticated", type: "gateway" },
352+ checkedAt: "2026-03-09T10:00:00.000Z",
353+ models: [],
354+ slashCommands: [],
355+ skills: [],
356+ ...overrides,
357+ } as ServerProvider;
358+ }
359+
360+ function makeThread(input: {
361+ readonly id: string;
362+ readonly updatedAt: string;
363+ readonly archivedAt?: string | null;
364+ }) {
365+ return {
366+ id: input.id,
367+ createdAt: input.updatedAt,
368+ updatedAt: input.updatedAt,
369+ latestUserMessageAt: input.updatedAt,
370+ messages: [],
371+ archivedAt: input.archivedAt ?? null,
372+ };
373+ }
374+
375+ const threads = [
376+ makeThread({ id: "thread-newest", updatedAt: "2026-03-09T12:00:00.000Z" }),
377+ makeThread({ id: "thread-middle", updatedAt: "2026-03-09T10:00:00.000Z" }),
378+ makeThread({ id: "thread-home", updatedAt: "2026-01-01T00:00:00.000Z" }),
379+ ];
380+
381+ it("reads the designation off the matching provider snapshot", () => {
382+ expect(
383+ resolveAgentHomeThreadId(
384+ [makeProvider({ homeThreadId: "thread-home" as never })],
385+ "hermes-main" as ProviderInstanceId,
386+ ),
387+ ).toBe("thread-home");
388+ });
389+
390+ it("reports no designation for an undesignated or absent provider", () => {
391+ expect(resolveAgentHomeThreadId([makeProvider()], "hermes-main" as ProviderInstanceId)).toBe(
392+ null,
393+ );
394+ expect(resolveAgentHomeThreadId([], "hermes-main" as ProviderInstanceId)).toBe(null);
395+ expect(
396+ resolveAgentHomeThreadId(
397+ [makeProvider({ homeThreadId: "thread-home" as never })],
398+ "hermes-other" as ProviderInstanceId,
399+ ),
400+ ).toBe(null);
401+ });
402+
403+ it("pins the home thread first even when it is the oldest", () => {
404+ expect(
405+ sortAgentPageThreads({
406+ threads,
407+ sortOrder: "updated_at",
408+ homeThreadId: "thread-home" as never,
409+ }).map((thread) => thread.id),
410+ ).toEqual(["thread-home", "thread-newest", "thread-middle"]);
411+ });
412+
413+ it("leaves an undesignated agent's list in plain recency order", () => {
414+ expect(
415+ sortAgentPageThreads({ threads, sortOrder: "updated_at", homeThreadId: null }).map(
416+ (thread) => thread.id,
417+ ),
418+ ).toEqual(["thread-newest", "thread-middle", "thread-home"]);
419+ });
420+
421+ it("excludes archived threads", () => {
422+ expect(
423+ sortAgentPageThreads({
424+ threads: [
425+ ...threads,
426+ makeThread({
427+ id: "thread-archived",
428+ updatedAt: "2026-03-09T13:00:00.000Z",
429+ archivedAt: "2026-03-09T13:30:00.000Z",
430+ }),
431+ ],
432+ sortOrder: "updated_at",
433+ homeThreadId: "thread-home" as never,
434+ }).map((thread) => thread.id),
435+ ).toEqual(["thread-home", "thread-newest", "thread-middle"]);
436+ });
437+});
apps/web/src/components/agents/AgentPage.logic.ts+43−0
@@ -2,11 +2,15 @@ import type { · 1 unmodified line
22 ProviderInstanceConnection,
33 ProviderInstanceDescription,
44 ProviderInstanceId,
5+ ServerProvider,
56 ServerProviderSkill,
7+ ThreadId,
68 } from "@t3tools/contracts";
9+import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";
710
811 import { getProviderVersionLabel, type ProviderStatusKey } from "../settings/providerStatus";
912 import { searchProviderSkills } from "../../providerSkillSearch";
13+import { sortThreads, type ThreadSortInput } from "../../lib/threadSort";
1014
1115 /**
1216 * Placeholder for any field the driver did not report.
@@ -283,6 +287,45 @@ export function isAgentInstanceNotFoundError(error: unknown): boolean { · 270 unmodified lines
283287 );
284288 }
285289
290+/**
291+ * The instance's designated home thread, off the pushed provider snapshot.
292+ *
293+ * Read from `ServerProvider` rather than `ProviderInstanceDescription`: the
294+ * snapshot is re-emitted on every status tick, so a designation made after
295+ * the page mounted arrives without a re-describe. Absent for every non-Hermes
296+ * driver and for a Hermes instance that has never completed a handshake —
297+ * both are `null`, because both mean "this list has no pinned row".
298+ */
299+export function resolveAgentHomeThreadId(
300+ providers: ReadonlyArray<ServerProvider>,
301+ instanceId: ProviderInstanceId | string,
302+): ThreadId | null {
303+ const provider = providers.find((candidate) => candidate.instanceId === instanceId);
304+ return provider?.homeThreadId ?? null;
305+}
306+
307+/**
308+ * The agent's thread list for the page, home first.
309+ *
310+ * Archived threads are excluded for the same reason the sidebar excludes
311+ * them — this list is "what can I open right now?", and the sort is the same
312+ * shared `sortThreads` the sidebar uses so the two surfaces cannot disagree
313+ * about order.
314+ */
315+export function sortAgentPageThreads<
316+ T extends { readonly id: string; readonly archivedAt: string | null } & ThreadSortInput,
317+>(input: {
318+ readonly threads: ReadonlyArray<T>;
319+ readonly sortOrder: SidebarThreadSortOrder;
320+ readonly homeThreadId: ThreadId | null;
321+}): T[] {
322+ return sortThreads(
323+ input.threads.filter((thread) => thread.archivedAt === null),
324+ input.sortOrder,
325+ input.homeThreadId,
326+ );
327+}
328+
286329 /**
287330 * Route param → branded instance id.
288331 *
apps/web/src/components/agents/AgentPage.tsx+156−1
@@ -1,18 +1,22 @@
11 "use client";
22
33 import { useAtomValue } from "@effect/atom-react";
4−import { scopeProjectRef } from "@t3tools/client-runtime/environment";
4+import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
55 import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
6+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
67 import type {
78 ProviderInstanceDescription,
89 ProviderInstanceId,
910 ServerProviderSkill,
1011 } from "@t3tools/contracts";
12+import type { SidebarThreadSortOrder, TimestampFormat } from "@t3tools/contracts/settings";
1113 import { useNavigate } from "@tanstack/react-router";
1214 import {
1315 ChevronRightIcon,
1416 CpuIcon,
17+ HouseIcon,
1518 LoaderIcon,
19+ MessageSquareIcon,
1620 MessageSquarePlusIcon,
1721 PlugZapIcon,
1822 RefreshCwIcon,
@@ -20,19 +24,25 @@ import { · 1 unmodified line
2024 ServerIcon,
2125 SettingsIcon,
2226 SparklesIcon,
27+ Trash2Icon,
2328 } from "lucide-react";
2429 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2530
2631 import { useNewThreadHandler } from "../../hooks/useHandleNewThread";
32+import { useClientSettings } from "../../hooks/useSettings";
33+import { useThreadActions } from "../../hooks/useThreadActions";
2734 import { cn } from "../../lib/utils";
2835 import { formatProviderDriverKindLabel } from "../../providerModels";
2936 import {
3037 formatProviderSkillDisplayName,
3138 formatProviderSkillInstallSource,
3239 } from "../../providerSkillPresentation";
40+import { useProjects, useThreadShellsForProjectRefs } from "../../state/entities";
3341 import { usePrimaryEnvironment } from "../../state/environments";
3442 import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server";
3543 import { useAtomCommand } from "../../state/use-atom-command";
44+import { buildThreadRouteParams } from "../../threadRoutes";
45+import { formatShortTimestamp } from "../../timestampFormat";
3646 import ChatMarkdown from "../ChatMarkdown";
3747 import { hermesGatewayProviderSignal } from "../settings/HermesGatewayInstanceSection.logic";
3848 import { PROVIDER_STATUS_STYLES } from "../settings/providerStatus";
@@ -61,7 +71,9 @@ import { · 22 unmodified lines
6171 isAgentPageBusy,
6272 messageFromAgentPageError,
6373 nextExpandedSkillName,
74+ resolveAgentHomeThreadId,
6475 shouldFetchAgentSkillBody,
76+ sortAgentPageThreads,
6577 supportsGatewayReconnect,
6678 upsertAgentSkillBody,
6779 type AgentPageAction,
@@ -173,6 +185,61 @@ function AgentSkillRow(props: { · 105 unmodified lines
173185 );
174186 }
175187
188+/**
189+ * One row in the agent's thread list.
190+ *
191+ * The home row gets the same restrained treatment the sidebar uses: a muted
192+ * leading glyph and no Delete. The title is whatever the thread is called
193+ * ("Home") — plain text, no emoji.
194+ */
195+function AgentThreadRow(props: {
196+ readonly thread: EnvironmentThreadShell;
197+ readonly isHomeThread: boolean;
198+ readonly timestampFormat: TimestampFormat;
199+ readonly onOpen: (thread: EnvironmentThreadShell) => void;
200+ readonly onDelete: ((thread: EnvironmentThreadShell) => void) | null;
201+}) {
202+ const { isHomeThread, thread } = props;
203+ return (
204+ <div className="group/agent-thread relative flex items-center border-b border-border/50 last:border-b-0">
205+ <button
206+ type="button"
207+ className="flex min-w-0 flex-1 items-center gap-2 px-2 py-2.5 text-left hover:bg-accent/40"
208+ data-testid={`agent-thread-row-${thread.id}`}
209+ onClick={() => props.onOpen(thread)}
210+ >
211+ {isHomeThread ? (
212+ <HouseIcon
213+ aria-label="Home thread"
214+ className="size-3.5 shrink-0 text-muted-foreground/70"
215+ />
216+ ) : (
217+ <MessageSquareIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground/40" />
218+ )}
219+ <span className="min-w-0 flex-1 truncate text-sm text-foreground" title={thread.title}>
220+ {thread.title}
221+ </span>
222+ <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
223+ {formatShortTimestamp(thread.updatedAt, props.timestampFormat)}
224+ </span>
225+ </button>
226+ {/* Withheld for the home thread: the server's decider rejects the
227+ delete, so the button could only ever produce an error. */}
228+ {props.onDelete && !isHomeThread ? (
229+ <button
230+ type="button"
231+ aria-label={`Delete ${thread.title}`}
232+ data-testid={`agent-thread-delete-${thread.id}`}
233+ className="mr-1.5 inline-flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/12 hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring group-hover/agent-thread:opacity-100"
234+ onClick={() => props.onDelete?.(thread)}
235+ >
236+ <Trash2Icon className="size-3.5" />
237+ </button>
238+ ) : null}
239+ </div>
240+ );
241+}
242+
176243 export function AgentPage({ instanceId }: { readonly instanceId: ProviderInstanceId }) {
177244 const environmentId = usePrimaryEnvironment()?.environmentId ?? null;
178245 const navigate = useNavigate();
@@ -194,6 +261,7 @@ export function AgentPage({ instanceId }: { readonly instanceId: ProviderInstanc · 15 unmodified lines
194261 const getGatewayStatus = useAtomCommand(serverEnvironment.hermesGatewayGetInstanceStatus, {
195262 reportFailure: false,
196263 });
264+ const { deleteThread } = useThreadActions();
197265
198266 const [description, setDescription] = useState<ProviderInstanceDescription | null>(null);
199267 const [error, setError] = useState<string | null>(null);
@@ -323,6 +391,62 @@ export function AgentPage({ instanceId }: { readonly instanceId: ProviderInstanc · 123 unmodified lines
323391 })();
324392 };
325393
394+ // Threads come off the same shell projection every other surface reads —
395+ // the sidebar, the command palette — rather than a page-local fetch, so the
396+ // list repaints live as the agent works and never disagrees with the
397+ // sidebar's copy of the same rows. The agent's synthetic project is found by
398+ // its `agentInstanceId` discriminator, which is exactly how the sidebar
399+ // recognizes an agent row.
400+ const projects = useProjects();
401+ const agentProjectRefs = useMemo(
402+ () =>
403+ projects
404+ .filter((project) => project.agentInstanceId === instanceId)
405+ .map((project) => scopeProjectRef(project.environmentId, project.id)),
406+ [instanceId, projects],
407+ );
408+ const agentThreads = useThreadShellsForProjectRefs(agentProjectRefs);
409+ const threadSortOrder = useClientSettings<SidebarThreadSortOrder>(
410+ (settings) => settings.sidebarThreadSortOrder,
411+ );
412+ const timestampFormat = useClientSettings<TimestampFormat>(
413+ (settings) => settings.timestampFormat,
414+ );
415+ const homeThreadId = useMemo(
416+ () => resolveAgentHomeThreadId(providers, instanceId),
417+ [instanceId, providers],
418+ );
419+ const visibleThreads = useMemo(
420+ () => sortAgentPageThreads({ threads: agentThreads, sortOrder: threadSortOrder, homeThreadId }),
421+ [agentThreads, homeThreadId, threadSortOrder],
422+ );
423+
424+ const handleOpenThread = useCallback(
425+ (thread: EnvironmentThreadShell) => {
426+ void navigate({
427+ to: "/$environmentId/$threadId",
428+ params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)),
429+ });
430+ },
431+ [navigate],
432+ );
433+ const handleDeleteThread = useCallback(
434+ (thread: EnvironmentThreadShell) => {
435+ void (async () => {
436+ const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id));
437+ if (result._tag === "Failure") {
438+ const failure = squashAtomCommandFailure(result);
439+ toastManager.add({
440+ type: "error",
441+ title: "Failed to delete thread",
442+ description: messageFromAgentPageError(failure),
443+ });
444+ }
445+ })();
446+ },
447+ [deleteThread],
448+ );
449+
326450 const status = description?.connection.status ?? "offline";
327451 const isBusy = isAgentPageBusy(pendingAction);
328452 const title = agentPageTitle(description, instanceId);
@@ -580,6 +704,37 @@ export function AgentPage({ instanceId }: { readonly instanceId: ProviderInstanc · 251 unmodified lines
580704 </SectionCard>
581705 </SettingsSection>
582706
707+ <SettingsSection
708+ title="Threads"
709+ icon={<MessageSquareIcon className="size-4" />}
710+ headerAction={
711+ visibleThreads.length > 0 ? (
712+ <span className="text-xs text-muted-foreground">{visibleThreads.length}</span>
713+ ) : null
714+ }
715+ >
716+ <SectionCard>
717+ {visibleThreads.length === 0 ? (
718+ <p className="text-xs text-muted-foreground/70 italic">
719+ No threads yet. Start one with “New thread” above.
720+ </p>
721+ ) : (
722+ <div className="min-w-0 rounded-lg border border-border/60 bg-background/60">
723+ {visibleThreads.map((thread) => (
724+ <AgentThreadRow
725+ key={`${thread.environmentId}:${thread.id}`}
726+ thread={thread}
727+ isHomeThread={homeThreadId !== null && thread.id === homeThreadId}
728+ timestampFormat={timestampFormat}
729+ onOpen={handleOpenThread}
730+ onDelete={handleDeleteThread}
731+ />
732+ ))}
733+ </div>
734+ )}
735+ </SectionCard>
736+ </SettingsSection>
737+
583738 <SettingsSection title="MCP servers" icon={<ServerIcon className="size-4" />}>
584739 <SectionCard>
585740 <p className="text-xs text-muted-foreground/70 italic">
apps/web/src/components/chat/MessagesTimeline.tsx+31−0
@@ -39,6 +39,7 @@ import { · 38 unmodified lines
3939 } from "../../lib/diffRendering";
4040 import ChatMarkdown from "../ChatMarkdown";
4141 import {
42+ BellIcon,
4243 BotIcon,
4344 CheckIcon,
4445 ChevronDownIcon,
@@ -81,6 +82,10 @@ import { · 36 unmodified lines
8182 TIMELINE_MINIMAP_MIN_ITEMS,
8283 type TimelineLatestTurn,
8384 } from "./MessagesTimeline.logic";
85+import {
86+ resolveNotificationBadge,
87+ type NotificationBadgePresentation,
88+} from "./notificationBadge.logic";
8489 import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
8590 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
8691 import {
@@ -1015,13 +1020,39 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn- · 928 unmodified lines
10151020 );
10161021 }
10171022
1023+/**
1024+ * Provenance chip above a proactive agent delivery.
1025+ *
1026+ * Deliberately not a status pill: it is inline, unpadded on the left, and
1027+ * sits in the message's own column so the body below reads as the same
1028+ * assistant message it would have been without it.
1029+ */
1030+function NotificationBadge({ badge }: { badge: NotificationBadgePresentation }) {
1031+ return (
1032+ <div
1033+ className={cn(
1034+ "mb-1 inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
1035+ badge.muted
1036+ ? "bg-muted/50 text-muted-foreground/80"
1037+ : "bg-accent/60 text-accent-foreground",
1038+ )}
1039+ data-notification-badge
1040+ >
1041+ <BellIcon className="size-3 opacity-70" />
1042+ <span className="min-w-0 truncate normal-case tracking-normal">{badge.label}</span>
1043+ </div>
1044+ );
1045+}
1046+
10181047 function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
10191048 const ctx = use(TimelineRowCtx);
10201049 const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
1050+ const notificationBadge = resolveNotificationBadge(row.message.notification);
10211051
10221052 return (
10231053 <>
10241054 <div className="relative min-w-0 px-1 py-0.5">
1055+ {notificationBadge ? <NotificationBadge badge={notificationBadge} /> : null}
10251056 <ChatMarkdown
10261057 text={messageText}
10271058 cwd={ctx.markdownCwd}
apps/web/src/components/chat/notificationBadge.logic.test.ts+63−0
@@ -0,0 +1,63 @@
1+import type { OrchestrationMessageNotification } from "@t3tools/contracts";
2+import { describe, expect, it } from "vite-plus/test";
3+
4+import { resolveNotificationBadge } from "./notificationBadge.logic";
5+
6+function makeNotification(
7+ overrides: Partial<OrchestrationMessageNotification> = {},
8+): OrchestrationMessageNotification {
9+ return {
10+ kind: "cron",
11+ label: "daily-digest",
12+ deliveryId: "delivery-1",
13+ ...overrides,
14+ } as OrchestrationMessageNotification;
15+}
16+
17+describe("resolveNotificationBadge", () => {
18+ it("renders no badge for an ordinary assistant message", () => {
19+ // The overwhelmingly common case — a reply to a turn the user started.
20+ expect(resolveNotificationBadge(undefined)).toBe(null);
21+ });
22+
23+ it("joins the kind and the label for a cron delivery", () => {
24+ expect(resolveNotificationBadge(makeNotification())).toEqual({
25+ label: "Cron · daily-digest",
26+ muted: false,
27+ });
28+ });
29+
30+ it("labels an agent-initiated send as Agent, not Message", () => {
31+ expect(
32+ resolveNotificationBadge(makeNotification({ kind: "message", label: "check-in" })),
33+ ).toEqual({ label: "Agent · check-in", muted: false });
34+ });
35+
36+ it("mutes lifecycle notices", () => {
37+ expect(
38+ resolveNotificationBadge(makeNotification({ kind: "lifecycle", label: "gateway online" })),
39+ ).toEqual({ label: "Gateway · gateway online", muted: true });
40+ });
41+
42+ it("keeps handoff and other at normal weight", () => {
43+ expect(
44+ resolveNotificationBadge(makeNotification({ kind: "handoff", label: "to laptop" })),
45+ ).toEqual({ label: "Handoff · to laptop", muted: false });
46+ expect(resolveNotificationBadge(makeNotification({ kind: "other", label: "misc" }))).toEqual({
47+ label: "Notification · misc",
48+ muted: false,
49+ });
50+ });
51+
52+ it("collapses to the kind alone when the label adds nothing", () => {
53+ // Agent-authored free text: it may be blank after trimming, or merely
54+ // repeat the kind. Either way a dangling separator would be noise.
55+ expect(resolveNotificationBadge(makeNotification({ label: " " }))).toEqual({
56+ label: "Cron",
57+ muted: false,
58+ });
59+ expect(
60+ resolveNotificationBadge(makeNotification({ kind: "lifecycle", label: "gateway" })),
61+ ).toEqual({ label: "Gateway", muted: true });
62+ });
63+});
apps/web/src/components/chat/notificationBadge.logic.ts+59−0
@@ -0,0 +1,59 @@
1+import type { OrchestrationMessageNotification } from "@t3tools/contracts";
2+
3+/**
4+ * Presentation for the small header badge above a proactive agent delivery.
5+ *
6+ * A notification is provenance, not content: the row underneath stays an
7+ * ordinary assistant message (selectable, copyable, part of the transcript)
8+ * and the badge only answers "who asked for this, since I didn't?".
9+ */
10+export interface NotificationBadgePresentation {
11+ /** Rendered text — "Cron · daily-digest", or just the kind when unlabelled. */
12+ readonly label: string;
13+ /**
14+ * `lifecycle` notices ("gateway online") are bookkeeping the user did not
15+ * ask for and will not act on, so they get the quieter treatment. Everything
16+ * else is content someone scheduled or sent and reads at normal weight.
17+ */
18+ readonly muted: boolean;
19+}
20+
21+/**
22+ * Human name for a notification kind.
23+ *
24+ * `message` renders as "Agent" rather than "Message": every row in the
25+ * timeline is a message, so the useful distinction is that this one was the
26+ * agent's own initiative.
27+ */
28+const NOTIFICATION_KIND_LABELS: Record<OrchestrationMessageNotification["kind"], string> = {
29+ cron: "Cron",
30+ message: "Agent",
31+ lifecycle: "Gateway",
32+ handoff: "Handoff",
33+ other: "Notification",
34+};
35+
36+/**
37+ * Badge for one message, or `null` when the message is an ordinary reply.
38+ *
39+ * "No notification" is the overwhelmingly common case and must never
40+ * synthesize a badge — an ordinary answer with a "Notification" chip on it
41+ * would be a lie about where it came from.
42+ *
43+ * The kind prefix is always shown even when a label repeats it, because the
44+ * label is agent-authored free text and cannot be trusted to carry the kind.
45+ * A label that is empty after trimming (or exactly the kind label) collapses
46+ * to the kind alone rather than rendering a dangling separator.
47+ */
48+export function resolveNotificationBadge(
49+ notification: OrchestrationMessageNotification | undefined,
50+): NotificationBadgePresentation | null {
51+ if (!notification) return null;
52+ const kindLabel = NOTIFICATION_KIND_LABELS[notification.kind] ?? NOTIFICATION_KIND_LABELS.other;
53+ const detail = notification.label.trim();
54+ const label =
55+ detail === "" || detail.toLowerCase() === kindLabel.toLowerCase()
56+ ? kindLabel
57+ : `${kindLabel} · ${detail}`;
58+ return { label, muted: notification.kind === "lifecycle" };
59+}
docs/project/hermes-home-channel-plan.md+165−13
@@ -23,7 +23,10 @@ Three independent gates block Hermes-initiated messages into T3: · 22 unmodified lines
2323
2424 1. **Plugin turn gate** — every emitter requires `_active_turns[thread_id]`, populated
2525 only by a T3-issued `turn.start`; `send()` fails with `"no active T3 turn"`
26− (`adapter.py:207-209`, `:254-256`).
26+ (`adapter.py:209`, `:256`). Since the finalize-bug fixes (`3f71c601a`), turn
27+ completion is keyed **exclusively** off `notify=True` metadata on `send()`
28+ (`adapter.py:212-217`); `finalize` on `edit_message` is ignored outright. Any
29+ proactive branch added to `send()` must respect that contract — see Part 3.
2730 2. **Contract gate** — no plugin→T3 frame in protocol v2 opens a session or turn
2831 (`packages/contracts/src/hermesGateway.ts:672-694`); everything is a reply carrying
2932 a T3-minted `requestId`.
@@ -190,19 +193,63 @@ env-only resolution path is always in sync with T3's durable designation. · 160 unmodified lines
190193
191194 ### Adapter delivery path
192195
193−`T3PlatformAdapter.send(chat_id, content, metadata)` grows a proactive branch: when
194−there is **no active turn** for the target and `chat_id` equals the home thread id,
195−emit `home.deliver` instead of failing with `"no active T3 turn"`. Kind/label
196−classification is best-effort from what Hermes passes in `metadata` and the send
197−context (cron deliveries are identifiable; gateway startup/shutdown broadcasts match
198−known message shapes; everything else defaults to `kind: "message"`, label "Hermes").
199−Sends to a non-home thread without an active turn keep the current explicit error —
200−scope creep into "message any thread unprompted" is deliberately excluded.
201−
202−The `/sethome` nudge suppression (`adapter.py:41-46`, `:211-214`, `:258-259`) stays:
203−once `env_enablement_fn` seeds a home channel Hermes stops emitting the nudge
196+`T3PlatformAdapter.send(chat_id, content, metadata)` grows a proactive branch for the
197+home thread. **The gate must be provenance, not merely turn absence.** The naive rule
198+("no active turn for this thread → `home.deliver`") deadlocks against the
199+notify-completion contract when the home thread itself has a live turn: a cron or
200+`send_message` delivery targeting the home chat while the user is mid-conversation
201+there would fall into the active-turn path, be streamed as that turn's assistant
202+content, and — because final cron deliveries arrive notify-stamped — **complete the
203+user's live turn with the cron output as its answer**. That is the same
204+keyed-off-the-wrong-signal defect class COMPATIBILITY.md documents for `finalize`.
205+
206+The discriminator already exists: `_gateway_session_key()` (`adapter.py`, added in
207+`3f71c601a`) reads `HERMES_SESSION_KEY` from Hermes' session contextvar. A genuine
208+turn reply is produced inside the turn's session context, so the key resolves to this
209+plugin's `build_session_key` id for that thread; cron deliveries run in their own
210+`cron_*` session and broadcasts run in none, so the key is absent or names a different
211+session. The branch rule in `send()` is therefore:
212+
213+- Active turn for `chat_id` **and** session key matches that turn's session → normal
214+ turn path (`_emit_assistant_content`, `notify` completes the turn).
215+- `chat_id` is the home thread and the session key does **not** match an active turn
216+ there (or no turn exists) → emit `home.deliver`. Never touch `_active_turns`.
217+- Non-home thread without a matching active turn → keep the current explicit
218+ `"no active T3 turn"` error; "message any thread unprompted" stays out of scope.
219+
220+Kind/label classification is best-effort from `metadata` and the send context (cron
221+deliveries are identifiable; gateway startup/shutdown broadcasts match known message
222+shapes; everything else defaults to `kind: "message"`, label "Hermes").
223+
224+Three constraints from the turn-lifecycle fixes (`3f71c601a`, `a4000b86d`,
225+COMPATIBILITY.md "Turn completion is keyed off `notify`, never `finalize`") shape
226+this branch:
227+
228+- **Ordering inside `send()`.** The steer-control capture
229+ (`_capture_steer_control_response`, `adapter.py:205`) must stay first — steer
230+ acknowledgements arrive with `notify=True` and must never be misread as proactive
231+ deliveries. The proactive branch slots after capture, gated by the provenance rule
232+ above.
233+- **`notify` does not end anything here.** In the active-turn path `notify=True`
234+ triggers `_complete_turn`; in the proactive branch there is no turn to complete —
235+ the metadata is consumed only as a classification hint (final cron deliveries
236+ arrive notify-stamped via `_mark_notify_metadata`, `gateway/platforms/base.py:89`).
237+ The branch must not touch `_active_turns` or emit any turn/item frames — only
238+ `home.deliver`.
239+- **Proactive edits are not supported.** Hermes' progress loop may follow a `send`
240+ with `edit_message` calls against the returned `message_id` (the tool-progress
241+ bubble pattern). `edit_message` outside an active turn keeps returning
242+ `"no active T3 turn"` — a delivery is an atomic document, not a streaming surface.
243+ In practice cron/`send_message`/lifecycle deliveries are single sends; if an
244+ upstream path ever streams a home delivery, revisit with a
245+ `home.deliver`-supersedes-by-`deliveryId` scheme rather than edit frames.
246+
247+The `/sethome` nudge suppression (`adapter.py:41-46`; checks at `:211`, `:258`)
248+stays: once `env_enablement_fn` seeds a home channel Hermes stops emitting the nudge
204249 naturally, but the suppression covers the pre-designation window (first connect before
205−any `connection.accepted` has written the env var).
250+any `connection.accepted` has written the env var). Note the nudge check now also
251+gates on `notify` for turn completion (`adapter.py:211-214`) — the suppression tests
252+in `tests/test_adapter.py` pin this and must keep passing.
206253
207254 ### Durable plugin-side queue
208255
@@ -238,6 +285,88 @@ location and any metadata fields read for classification are new entries). · 29 unmodified lines
238285 (server-side decider rejection is the real guard; UI just avoids offering it).
239286 - Composer in the home thread is the normal Hermes composer — replying starts an
240287 ordinary `session.ensure`/`turn.start` flow on the same `threadId`.
288+- **Pinning must survive the sidebar dedupe.** `visibleSidebarSections` now
289+ de-duplicates by `projectKey` (`Sidebar.tsx:3555+`, added in `3f71c601a`'s batch)
290+ and `getSidebarThreadIdsToPrewarm` dedupes thread ids. Home-thread pinning is a
291+ sort inside one section's thread list, so it composes with both — but the pinned
292+ ordering must be applied before the prewarm slice so Home is always warm.
293+
294+---
295+
296+## Part 5 — Bundled fix: settled-turn activity escapes the "Worked for …" fold
297+
298+Not home-channel machinery, but it lands in the same surfaces (Hermes activity
299+ordering in the timeline) and the home thread will render notification + turn
300+activity constantly, so it ships with this work.
301+
302+**Symptom.** After a Hermes turn settles, its activity should collapse behind the
303+"Worked for …" fold above the final answer. Instead the activity splits: one cluster
304+(work summary) folds correctly above, and a second cluster (the steps taken /
305+"Work Log" with rows like "Hermes activity is running …") renders **below** the final
306+assistant message, outside the fold. Both belong above.
307+
308+**What's already fixed.** `a4000b86d` reordered `_complete_turn` to close the live
309+status item before the assistant message, because T3's fold only captures entries
310+preceding the turn's terminal message — the status item was being stamped ~2ms after
311+the answer and escaping. That fix is correct but insufficient: the symptom still
312+reproduces (observed post-fix with a "Work Log — Hermes activity" section below the
313+answer), which means at least one more path produces late- or mis-attributed entries.
314+
315+**How the fold actually works** (`MessagesTimeline.logic.ts`):
316+
317+- `deriveTimelineEntries` (`session-logic.ts:1340-1366`) merges messages, plans, and
318+ work entries and sorts **purely by `createdAt` string compare**. Assistant messages
319+ sort at their _creation_ time (early in the turn); activities re-stamp `createdAt`
320+ on every update (the projector replaces by id and re-sorts,
321+ `projector.ts:725-753`), so a turn's activities always sort _after_ its assistant
322+ message. Position alone is therefore expected to be "activities below".
323+- The fold compensates: `deriveTurnFolds` (`MessagesTimeline.logic.ts:285-404`)
324+ groups entries by `turnId` and hides **every non-terminal entry of a settled turn
325+ regardless of timestamp position**. So a trailing activity row that carries the
326+ right `turnId` cannot escape the fold.
327+- Consequently, an entry rendering below the final message **must have failed turn
328+ attribution** — grouping skips entries whose `turnId` resolves null
329+ (`MessagesTimeline.logic.ts:311-318`), and unattributed entries can never be hidden.
330+
331+**Candidate mechanisms to run down, in likelihood order:**
332+
333+1. **Late frames lose their turn.** Plugin tool hooks run on Hermes tool worker
334+ threads; a `post_tool_call` → `item.completed` frame can arrive after
335+ `turn.completed`. `ProviderRuntimeIngestion`'s turn-conflict gate
336+ (`ProviderRuntimeIngestion.ts:1370-1411`) rejects events naming a non-active turn
337+ — but any acceptance path that strips or nulls `turnId` (or an ingestion branch
338+ that emits the activity with `toTurnId(...) ?? null`) produces exactly this
339+ symptom. Check what happens to an `item.*` frame for a just-completed turn.
340+2. **Command-dispatch race.** Activity appends and message finalization are separate
341+ orchestration commands; wire order (status completed before assistant, per
342+ `a4000b86d`) does not guarantee commit order. If the activity append commits after
343+ the turn record settles, does any code path re-key or drop its `turnId`?
344+3. **Fallback attribution gap.** `_turn_for_tool_hook`'s sole-active-turn fallback
345+ emits nothing when it cannot resolve — but the _generic status_ item
346+ (`_ensure_generic_activity`) and any frames emitted between `notify` completion
347+ and socket flush should be audited for a window where `turn.turn_id` is stale or
348+ the frame is built without it.
349+
350+The decisive experiment: reproduce, then inspect the projected thread — the escaping
351+activity row's `turnId` in `projection_thread_activities`. Null confirms attribution
352+loss (mechanisms 1/3); a correct `turnId` with the row still visible would falsify
353+the fold analysis and point back at web-side grouping.
354+
355+**Fix direction.** Two layers, both worth doing:
356+
357+- Root cause: guarantee `turnId` attribution end-to-end for every activity a turn
358+ produces, including frames that land after `turn.completed` (accept-and-attribute
359+ late items for a recently-completed turn rather than dropping/nulling).
360+- Defense: the fold should not depend on perfect attribution. For a settled turn,
361+ any work entry timestamped between the turn's first entry and its terminal
362+ message's `updatedAt` (plus a small trailing grace window) with no other turn
363+ claiming it belongs to that turn's fold. This also future-proofs the home thread,
364+ where notification rows and turn activity will interleave.
365+
366+Regression test: replay the recorded frame sequence of a real Hermes turn (the
367+existing `test_adapter.py` frame-replay pattern) plus a late `item.completed`, and
368+assert the derived timeline rows place no work/activity row after the terminal
369+assistant message of a settled turn.
241370
242371 ---
243372
@@ -259,6 +388,9 @@ Each step typechecks and is testable on its own: · 15 unmodified lines
259388 6. **Web UI** — snapshot `homeThreadId`, pinned row, notification badges, delete
260389 suppression.
261390 7. **Push** — `AgentAwarenessRelay` acceptance + headline mapping.
391+8. **Fold-escape fix (Part 5)** — independent of 1–7; can land first. Doing it
392+ before the home-channel UI keeps notification rows from inheriting the same
393+ mis-ordering.
262394
263395 Steps 1–3 make T3 able to receive; step 4 makes the flagship flows (cron via live
264396 gateway, `send_message`, lifecycle, `/handoff`) work end to end; 5–7 complete it.
@@ -293,6 +425,14 @@ Manual checks: · 28 unmodified lines
293425 generation-bumping a live gateway socket (verify with a concurrent live connection).
294426 9. Regression: Claude/Codex threads and non-home Hermes threads unchanged; unsolicited
295427 sends to non-home threads still error.
428+10. Turn/delivery interleave in Home: while a user turn is running in the home
429+ thread, fire a `deliver=t3` cron job → the delivery lands as a notification row
430+ and the live turn keeps streaming and completes normally on its own `notify`
431+ (this is the provenance-gate check from Part 3 — the failure mode is the cron
432+ output completing the user's turn).
433+11. Part 5: settle a Hermes turn that used tools → all activity, including the
434+ status line and every tool row, folds behind "Worked for …"; nothing renders
435+ between or below the final answer.
296436
297437 Automated: `pnpm typecheck`, `pnpm test`, `pnpm lint`; plugin tests in
298438 `integrations/hermes-t3-gateway/tests` (queue FIFO + ack purge, nudge suppression
@@ -313,3 +453,15 @@ still pinned, proactive-send branch, standalone sender against a mock server); · 14 unmodified lines
313453 - **Lifecycle notice volume.** Frequent gateway restarts write quiet rows into Home.
314454 If that proves noisy, Hermes' per-platform `gateway_restart_notification: false`
315455 opt-out is the escape hatch (a one-line config change, no code).
456+- **The sole-active-turn tool-hook fallback misattributes cron tool activity.**
457+ `_turn_for_tool_hook` (added in `3f71c601a`) falls back to "the sole active turn"
458+ when no routing key resolves. The plugin's `pre/post_tool_call` hooks are
459+ process-global, so a **cron job running tools** while exactly one T3 turn is
460+ active resolves through that fallback and paints the cron job's tool calls into
461+ the unrelated live turn. Upstream is aware of the general hazard (the cron
462+ scheduler deliberately avoids the process-global env var for exactly this
463+ routing risk, `cron/scheduler.py:3077`). Low-frequency today; this feature makes
464+ `deliver=t3` cron jobs common, so fix alongside Part 3: before the sole-turn
465+ fallback, exclude hook calls whose `session_id` is a cron run id (`cron_*`
466+ prefix, `cron/scheduler.py:3017`) — their activity belongs to the eventual
467+ `home.deliver`, not to any live turn.
infra/relay/src/agentActivity/AgentActivityPublisher.ts+2−0
@@ -187,6 +187,8 @@ function statusForPhase(phase: RelayAgentActivityState["phase"]): string { · 186 unmodified lines
187187 return "Approval";
188188 case "waiting_for_input":
189189 return "Input";
190+ case "notification":
191+ return "Update";
190192 case "completed":
191193 return "Done";
192194 case "failed":
integrations/hermes-t3-gateway/COMPATIBILITY.md+189−5
@@ -3,6 +3,11 @@ · 2 unmodified lines
33 The plugin deliberately uses only the public Hermes plugin and platform-adapter
44 surfaces audited at Hermes Agent upstream commit `62e07223` (v0.19.0).
55
6+Scope note: this file inventories **upstream Hermes** surfaces only. T3-side
7+machinery the plugin talks to over the wire — `withHermesConfig`, the broker's
8+generation fencing, `getOrCreateHomeThread` — is not a Python concern and is
9+documented on the T3 side; only the wire contract those produce appears here.
10+
611 Audited surfaces, all present at that commit:
712
813 | Surface | Location at 62e07223 |
@@ -35,9 +40,37 @@ Audited surfaces, all present at that commit: · 26 unmodified lines
3540 | Active-command inline dispatch | `gateway/platforms/base.py:4926` |
3641 | User-plugin path `$HERMES_HOME/plugins/` | `hermes_cli/plugins.py:10`, `:1350` |
3742
38−This inventory describes gateway wire protocol v2. Protocol v2 adds active-turn
39−recovery in `session.ready` and authoritative `content.snapshot` replacement;
40−older server/plugin pairs are rejected during the handshake.
43+Home-channel surfaces, added for protocol v3:
44+
45+| Surface | Location at 62e07223 |
46+| ------------------------------------------ | ---------------------------------- |
47+| `cron_deliver_env_var` registration flag | `gateway/platform_registry.py:143` |
48+| `standalone_sender_fn` registration flag | `gateway/platform_registry.py:159` |
49+| Standalone-sender invocation | `tools/send_message_tool.py:741` |
50+| `_home_target_env_var` fallback convention | `gateway/run.py:1541` |
51+| `_resolve_home_env_var` (plugin lookup) | `cron/scheduler.py:1025` |
52+| `env_enablement_fn` `home_channel` promote | `gateway/config.py:2648` |
53+| `HomeChannel` dataclass | `gateway/config.py:421` |
54+| `get_home_channel` | `gateway/config.py:1022` |
55+| `get_hermes_home` (queue/state base) | `hermes_constants.py:106` |
56+| `get_hermes_home` re-export | `hermes_cli/config.py:686` |
57+| Cron run-id shape (`cron_*`) | `cron/scheduler.py:3017`, `:3484` |
58+| Cron session-var clearing | `cron/scheduler.py:3066-3091` |
59+| Cron `job_id` in routed metadata | `cron/scheduler.py:1782` |
60+| Cron metadata reaching `adapter.send` | `gateway/delivery.py:606` |
61+| Cron wrap header (`Cronjob Response: …`) | `cron/scheduler.py:1513` |
62+| Gateway online notice | `gateway/run.py:17277` |
63+| Gateway restart notice | `gateway/run.py:17236` |
64+| Gateway shutdown/restarting notice | `gateway/run.py:6599` |
65+| `/handoff` synthetic source identity | `gateway/run.py:8854` |
66+| `HERMES_SESSION_USER_ID` binding | `gateway/run.py:17372` |
67+| Session-context lifetime around a turn | `gateway/run.py:12972` → `:14626` |
68+
69+This inventory describes gateway wire protocol v3. Protocol v2 added active-turn
70+recovery in `session.ready` and authoritative `content.snapshot` replacement; v3
71+adds `role` on `connection.hello`, `homeThreadId` on `connection.accepted`, and
72+the `home.deliver` / `home.deliver.ack` pair. Older server/plugin pairs are
73+rejected during the handshake — the version policy stays fail-closed.
4174
4275 ## Mapped in the initial scope
4376
@@ -55,6 +88,7 @@ older server/plugin pairs are rejected during the handshake. · 11 unmodified lines
5588 | `load_config_readonly()["agent"]["reasoning_effort"]` | Optional `reasoningEffort` on `describe.response` |
5689 | `skills_list()` metadata | `skills` on `describe.response` |
5790 | `skill_view(name, preprocess=False)` | `markdown` on `skill.body.response` |
91+| Cron `deliver=t3`, `send_message t3`, lifecycle | `home.deliver` / `home.deliver.ack` |
5892
5993 ## Known limitations
6094
@@ -195,7 +229,18 @@ older server/plugin pairs are rejected during the handshake. · 134 unmodified lines
195229 and the wire type is non-empty. That takes the ordinary correlated
196230 `protocol.error` path.
197231 - Attachments are not accepted. They are the first planned post-stability
198− feature; the capability is reserved and fixed to `false` in protocol v2.
232+ feature; the capability is reserved and fixed to `false` in protocol v3. The
233+ `standalone_sender_fn` signature accepts `media_files` / `force_document` for
234+ parity with `gateway/platform_registry.py:150-158`, but the v3 wire contract
235+ has no attachment framing, so both are ignored (logged, not silently
236+ dropped).
237+- **Kind/label classification is heuristic.** `adapter.send()` carries no
238+ structured "this is a cron delivery" marker on every path, so the plugin
239+ reads what does exist (see "Home-channel delivery" below). A
240+ misclassification costs a wrong badge — and, for `lifecycle`, a delivery that
241+ raises its hand when it should have landed quietly — never a lost delivery.
242+ If upstream ever exposes delivery provenance in metadata, adopt it and
243+ replace the heuristics here.
199244
200245 ## Turn completion is keyed off `notify`, never `finalize`
201246
@@ -221,7 +266,7 @@ boundary, and `_complete_turn` is reached from nowhere else on the output path. · 19 unmodified lines
221266 ### The signal that does NOT end a turn: `finalize=True` on `edit_message`
222267
223268 `finalize` reads like "last edit of the response", and the base class documents
224−it that way (`gateway/platforms/base.py:3178-3186`). It is **not** a turn
269+it that way (`gateway/platforms/base.py:3176-3183`). It is **not** a turn
225270 boundary. Two upstream paths set it mid-turn:
226271
227272 1. **The tool-progress loop.** When an adapter declares
@@ -266,6 +311,145 @@ would hang in the running state with the full answer streamed but no · 38 unmodified lines
266311 `turn.completed`. That is the opposite failure mode from the original bug and
267312 would show up as spinners that never resolve, not truncated answers.
268313
314+## Home-channel delivery: the gate is provenance, not turn absence
315+
316+Hermes-initiated output — cron results, `send_message` with a bare `t3` target,
317+gateway lifecycle notices, `/handoff t3` — has no T3-issued turn to stream into.
318+It is emitted as `home.deliver` against the instance's durable home thread.
319+
320+### The deadlock this design exists to avoid
321+
322+The naive rule — "no active turn for this thread → deliver" — is wrong, and
323+wrong in the same keyed-off-the-wrong-signal way as the `finalize` bug above.
324+When the home thread itself has a live user turn, a cron delivery targeting it
325+would fall into the active-turn path, stream as that turn's assistant content,
326+and — because final cron deliveries arrive notify-stamped via
327+`_mark_notify_metadata` (`gateway/platforms/base.py:89`) — **complete the user's
328+live turn with the cron output as its answer**.
329+
330+The discriminator is `HERMES_SESSION_KEY`. The gateway binds it onto the turn's
331+context for the whole handler (`gateway/run.py:12972` → `:14626`, read via
332+`get_session_env`), and every send a turn produces — streamed or final — happens
333+inside that scope, so a genuine turn reply resolves to this plugin's
334+`build_session_key` id for its thread. Cron runs under its own
335+`cron_<job>_<timestamp>` session with the gateway routing keys explicitly
336+cleared (`cron/scheduler.py:3066-3091`), and lifecycle broadcasts run in no
337+session at all.
338+
339+`_is_proactive_delivery` therefore decides in this order:
340+
341+1. Session key matches an active turn on this thread → **turn content**, never
342+ a delivery. Checked first, so a turn reply can never be rerouted.
343+2. Not the home thread → never a delivery. "Message any thread unprompted"
344+ stays out of scope and the existing `"no active T3 turn"` error is returned
345+ verbatim.
346+3. Home thread, no active turn → delivery. There is nothing it could belong to.
347+4. Home thread **with** a non-matching active turn → delivery only when
348+ provenance is positively established. This is the conservative half: an
349+ unattributable send in that window stays with the turn (at worst misplaced
350+ inside the same thread) rather than being torn out of a turn it may belong
351+ to. So an unclassifiable send can never steal a live answer, and a
352+ recognisable cron/lifecycle/handoff delivery never completes one.
353+
354+Ordering inside `send()` is load-bearing: `_capture_steer_control_response`
355+stays first, because steer acknowledgements arrive with `notify=True` and must
356+never be read as deliveries.
357+
358+`edit_message` has **no** proactive branch and keeps returning `"no active T3
359+turn"` outside a turn. A delivery is an atomic document, not a streaming
360+surface. If an upstream path ever streams a home delivery, revisit with a
361+`home.deliver`-supersedes-by-`deliveryId` scheme rather than edit frames.
362+
363+### What classification keys off
364+
365+All best-effort, in precedence order, all degrading to
366+`("message", "Hermes", uncertain)`:
367+
368+- `metadata["job_id"]` — the only structured signal. The cron scheduler stamps
369+ it into the routed metadata (`cron/scheduler.py:1782`) and
370+ `DeliveryRouter._deliver_to_platform` passes the dict through to
371+ `adapter.send` unchanged (`gateway/delivery.py:606`).
372+- The cron wrap header `Cronjob Response: <name>` (`cron/scheduler.py:1513`),
373+ present whenever `cron.wrap_response` is on (the default), which also
374+ supplies the human job name for the badge.
375+- Lifecycle literals: `gateway/run.py:17277`, `:17236`, `:6599`. These are
376+ inline f-strings upstream, not exported constants, so they carry the same
377+ drift risk as the `/sethome` notice and the `⏩ Steer queued` prefix.
378+- `HERMES_SESSION_USER_ID == "system:handoff"`, the synthetic source identity
379+ `/handoff` dispatches under (`gateway/run.py:8854`, bound at `:17372`).
380+
381+### Registration contracts
382+
383+- **`cron_deliver_env_var="T3_HOME_CHANNEL"`.** The name is not free-form.
384+ `_home_target_env_var` (`gateway/run.py:1541`) consults built-ins, then the
385+ plugin registry via `_resolve_home_env_var` (`cron/scheduler.py:1025`), then
386+ falls back to `f"{PLATFORM.upper()}_HOME_CHANNEL"` — exactly this string for
387+ platform `t3`. Matching the fallback means `send_message`'s error hints and
388+ cron's env-only resolution agree with what the plugin writes, with no
389+ upstream override-table entry. Without the flag, `deliver=t3` is silently
390+ dropped by cron.
391+- **`env_enablement_fn` seeds `home_channel`.** That key is magic: core pops it
392+ out of the returned dict and promotes it to a real `HomeChannel` dataclass
393+ (`gateway/config.py:2648-2660`, reading only `chat_id` / `name` /
394+ `thread_id`). The promotion is what makes `get_home_channel("t3")`
395+ (`gateway/config.py:1022`) resolve, which is what makes `send_message`,
396+ lifecycle broadcasts, and `/handoff` work — core hardcodes env promotion only
397+ for built-ins. T3 threads are the addressing unit, so `chat_id` **is** the
398+ thread id and `thread_id` stays unset.
399+- **`standalone_sender_fn`.** Out-of-process cron has no live adapter
400+ (`tools/send_message_tool.py:741`). The plugin dials T3 itself over a
401+ short-lived socket announcing `role: "delivery"`. That role is load-bearing:
402+ T3's broker registers a `gateway` connection under generation fencing and
403+ displaces its predecessor, so a cron dial-in announcing the default role
404+ would kick the live gateway socket off its own instance mid-turn.
405+
406+### Designation is a synced cache, not local state
407+
408+`T3_HOME_CHANNEL` is written by the plugin, never by the user. T3's settings
409+blob is authoritative and republishes `homeThreadId` on every
410+`connection.accepted`; the plugin compares and persists via `save_env_value`
411+(the same profile-aware helper enrollment uses) and mirrors into `os.environ`
412+so a running gateway needs no restart. A hand-edited value is overwritten on
413+the next reconnect — documented in the README. A read-only or managed `.env`
414+degrades to the in-process mirror only: routing works for the life of the
415+process and re-reconciles on the next connect.
416+
417+### Queue and state location
418+
419+The plugin previously persisted nothing to disk. It now keeps one JSONL outbox
420+at `<hermes home>/gateway/t3_home_delivery_queue.jsonl`, using
421+`get_hermes_home()` (`hermes_constants.py:106`, re-exported at
422+`hermes_cli/config.py:686`) as the base — the same accessor and the same
423+`gateway/` subdirectory Hermes' own Discord adapter uses for per-profile
424+adapter state (`plugins/platforms/discord/adapter.py:52`, `:272`, `:1694`).
425+Resolving through that accessor rather than `~/.hermes` makes the queue
426+profile-scoped: a second profile cannot replay another profile's deliveries
427+into its own home thread.
428+
429+Correctness rests on one rule: an entry is removed **only** on its
430+`home.deliver.ack`. Everything else — a socket that dropped mid-send, a server
431+that died before writing, a plugin restart — leaves the entry to be replayed,
432+which is safe because T3 dedupes on `deliveryId`. Acking before the durable
433+write on the server side would break this. The queue is capped at 300 entries
434+and drops oldest-first with a logged warning; one flush replays at most 50
435+entries so a reconnect does not stall live traffic.
436+
437+### Cron tool-hook misattribution
438+
439+`_turn_for_tool_hook`'s sole-active-turn fallback is now skipped for cron runs.
440+The hooks are process-global, so a cron job running tools while exactly one T3
441+turn happens to be live would resolve through that fallback and paint the cron
442+job's tool calls into an unrelated live conversation. Cron runs are identifiable
443+by the `cron_<job>_<timestamp>` session id the scheduler mints
444+(`cron/scheduler.py:3017`, passed to the agent at `:3484`) — the exact value the
445+hooks receive. Upstream treats the same routing hazard as real, clearing the
446+process-global session env vars for it (`cron/scheduler.py:3066-3091`). A cron
447+job's activity belongs to the eventual `home.deliver`, never to a live turn.
448+
449+Prefix matching carries the usual drift risk: if upstream renames the shape,
450+this degrades to the previous behaviour (cron tool rows may again be
451+misattributed to a sole live turn) rather than breaking anything.
452+
269453 ## Tool-progress chrome: the `format_tool_event` override is not the defence
270454
271455 The plugin overrides `format_tool_event` to return `None`
integrations/hermes-t3-gateway/README.md+36−4
@@ -4,7 +4,7 @@ Experimental Hermes platform plugin for connecting one already-running Hermes · 3 unmodified lines
44 process to T3 Code. The plugin makes an outbound WebSocket connection; Hermes
55 does not need to listen on a public port.
66
7−The gateway wire protocol is v2. The T3 server and Hermes plugin must be updated
7+The gateway wire protocol is v3. The T3 server and Hermes plugin must be updated
88 together; mismatched versions fail the connection handshake closed.
99
1010 Each T3 thread maps deterministically to one Hermes gateway session. A new T3
@@ -54,6 +54,36 @@ The handshake also reports Hermes' configured default model so T3 can show a · 43 unmodified lines
5454 truthful label in its picker. It is read-only — Hermes owns model selection —
5555 and is omitted entirely if it cannot be read.
5656
57+## The Home thread
58+
59+Every enrolled instance gets one **Home** thread in T3, created automatically —
60+there is nothing to set up and nothing to choose. It receives all of Hermes'
61+proactive output: cron results (`deliver=t3`), the agent's `send_message` tool
62+with a bare `t3` target, gateway online/shutdown notices, and `/handoff t3`.
63+Replying in it runs an ordinary Hermes turn in the same thread.
64+
65+**T3 owns the designation, and `T3_HOME_CHANNEL` is a synced cache of it.** The
66+plugin writes that variable itself: T3 republishes the home thread id on every
67+successful handshake, and the plugin compares and persists it with Hermes'
68+profile-aware `save_env_value` helper. A hand-edited `T3_HOME_CHANNEL` will
69+therefore be **overwritten on the next reconnect** — to move the Home thread,
70+change it in T3, not in `.env`. (`/sethome` is likewise inert for this platform:
71+the designation is fixed, so Hermes' "set a home channel" nudge is suppressed.)
72+
73+Deliveries are durable in both directions. Each one is written to a small JSONL
74+queue at `<hermes home>/gateway/t3_home_delivery_queue.jsonl` before it is sent
75+and removed only once T3 acknowledges it, so nothing is lost if either side
76+restarts mid-flight; queued deliveries flush on the next connect, and T3
77+deduplicates so a replay is harmless. The queue is capped and drops oldest-first
78+with a logged warning rather than growing without bound.
79+
80+Cron works whether or not the gateway is co-resident. When `hermes cron` runs in
81+its own process there is no live adapter, so the plugin dials T3 over a
82+short-lived delivery connection — authenticated the same way, but never
83+registered as the instance's primary connection, so it cannot disturb a running
84+`hermes gateway`. If T3 is unreachable the delivery is queued and the cron job
85+still reports success.
86+
5787 ## Initial scope
5888
5989 - Text input and live assistant streaming
@@ -66,9 +96,11 @@ and is omitted entirely if it cannot be read. · 6 unmodified lines
6696 - Reconnect with bounded backoff
6797 - Version-incompatible and revoked credentials fail closed
6898
69−T3 threads are session-only in the initial scope. The adapter suppresses Hermes' exact
70−first-chat `/sethome` notice without assigning a home channel or redirecting
71−cron and cross-platform delivery.
99+- Proactive delivery into the Home thread: cron, `send_message`, lifecycle
100+ notices, `/handoff` — with a durable queue and out-of-process cron support
101+
102+Non-home T3 threads remain session-only: Hermes cannot message them unprompted,
103+and an unsolicited send to one still fails with `no active T3 turn`.
72104
73105 Attachments intentionally advertise `false`. Adding bounded image/file input is
74106 the first post-stability feature and should not reuse arbitrary raw payloads.
integrations/hermes-t3-gateway/__init__.py+18−0
@@ -10,6 +10,8 @@ from .adapter import ( · 9 unmodified lines
1010 validate_config,
1111 )
1212 from .cli import register_cli, t3_command
13+from .home import HOME_CHANNEL_ENV, standalone_send
14+from .home import HOME_CHANNEL_ENV, standalone_send
1315
1416
1517 def _pre_tool_call(
@@ -53,6 +55,22 @@ def register(ctx) -> None: · 37 unmodified lines
5355 "HERMES_T3_GATEWAY_CREDENTIAL",
5456 ],
5557 env_enablement_fn=env_enablement,
58+ # Cron home-channel delivery. The name is not free-form: Hermes
59+ # resolves a platform's cron home target through `_home_target_env_var`
60+ # (`gateway/run.py:1541`), which falls back to
61+ # f"{PLATFORM.upper()}_HOME_CHANNEL" for any platform without a
62+ # built-in override entry — exactly this string for platform `t3`. So
63+ # `send_message`'s error hints, `/sethome` messaging, and cron's
64+ # env-only resolution all agree with the value the plugin writes, with
65+ # no upstream override table entry. Without this, `deliver=t3` is
66+ # silently dropped by cron.
67+ cron_deliver_env_var=HOME_CHANNEL_ENV,
68+ # Out-of-process cron delivery: when `hermes cron` runs in a separate
69+ # process from `hermes gateway` there is no live adapter, and without
70+ # this hook `deliver=t3` fails with "No live adapter for platform".
71+ # Dials T3 over a short-lived `role: "delivery"` socket so it cannot
72+ # displace the live gateway connection.
73+ standalone_sender_fn=standalone_send,
5674 max_message_length=120_000,
5775 emoji="🔺",
5876 pii_safe=True,
integrations/hermes-t3-gateway/adapter.py+242−4
@@ -23,6 +23,15 @@ from gateway.session import build_session_key · 22 unmodified lines
2323
2424 from .cli import CREDENTIAL_ENV, INSTANCE_ID_ENV, NICKNAME_ENV, URL_ENV
2525 from .connection import T3GatewayConnection, dependency_available
26+from .home import (
27+ HOME_CHANNEL_ENV,
28+ MAX_FLUSH_PER_CONNECT,
29+ HomeDeliveryQueue,
30+ build_delivery,
31+ classify_delivery,
32+ home_thread_id,
33+ save_home_thread_id,
34+)
2635 from .protocol import (
2736 canonical_tool_data,
2837 canonical_tool_item_type,
@@ -139,6 +148,7 @@ class T3PlatformAdapter(BasePlatformAdapter): · 110 unmodified lines
139148 self._active_turns: dict[str, _TurnState] = {}
140149 self._approval_requests: dict[str, tuple[str, str]] = {}
141150 self._user_input_requests: dict[str, tuple[str, str]] = {}
151+ self._home_queue = HomeDeliveryQueue()
142152 # Strong references to fire-and-forget tasks. asyncio only holds a weak
143153 # reference to a running task, so without this the GC may collect one
144154 # mid-flight and its exception surfaces as a bare warning.
@@ -172,6 +182,7 @@ class T3PlatformAdapter(BasePlatformAdapter): · 27 unmodified lines
172182 hermes_version=_hermes_version(),
173183 on_message=self._handle_server_frame,
174184 on_state=self._handle_connection_state,
185+ on_accepted=self._handle_connection_accepted,
175186 )
176187 try:
177188 connected = await self._connection.connect()
@@ -204,7 +215,10 @@ class T3PlatformAdapter(BasePlatformAdapter): · 26 unmodified lines
204215 captured = self._capture_steer_control_response(chat_id, content, reply_to)
205216 if captured is not None:
206217 return captured
207− turn = self._active_turns.get(str(chat_id))
218+ thread_id = str(chat_id)
219+ turn = self._active_turns.get(thread_id)
220+ if self._is_proactive_delivery(thread_id, turn, content, metadata):
221+ return await self._deliver_to_home(thread_id, content, metadata)
208222 if turn is None:
209223 return SendResult(success=False, error="no active T3 turn")
210224 try:
@@ -265,6 +279,170 @@ class T3PlatformAdapter(BasePlatformAdapter): · 54 unmodified lines
265279 async def get_chat_info(self, chat_id: str) -> dict[str, Any]:
266280 return {"name": f"T3 thread {chat_id}", "type": "dm"}
267281
282+ # ── proactive home delivery ────────────────────────────────────────
283+
284+ def _is_proactive_delivery(
285+ self,
286+ thread_id: str,
287+ turn: _TurnState | None,
288+ content: str,
289+ metadata: dict[str, Any] | None,
290+ ) -> bool:
291+ """Decide whether this send is Hermes-initiated home delivery.
292+
293+ **The gate is provenance, not turn absence.** The naive rule ("no
294+ active turn for this thread → deliver") deadlocks against the
295+ notify-completion contract the moment the home thread has a live turn:
296+ a cron result or `send_message` targeting the home chat mid-conversation
297+ would take the active-turn path, stream as that turn's assistant
298+ content, and — because final cron deliveries arrive notify-stamped via
299+ `_mark_notify_metadata` (`gateway/platforms/base.py:89`) — **complete
300+ the user's live turn with the cron output as its answer.** That is the
301+ same keyed-off-the-wrong-signal defect class as the `finalize` bug.
302+
303+ The discriminator is `_gateway_session_key()`: the gateway binds
304+ `HERMES_SESSION_KEY` onto the turn's context for the whole handler
305+ (`gateway/run.py:12972` → `:14626`), and every send a turn produces —
306+ streamed or final — happens inside that scope. A genuine turn reply
307+ therefore resolves to this plugin's `build_session_key` id for its
308+ thread. Cron runs in its own `cron_*` session with the gateway keys
309+ explicitly cleared (`cron/scheduler.py:3066-3091`), and lifecycle
310+ broadcasts run in no session at all, so neither resolves to the live
311+ turn's key.
312+
313+ The rules, in order:
314+
315+ * A send whose session key matches an active turn on this thread is
316+ that turn's own output. Never a delivery — checked first so a turn
317+ reply can never be rerouted.
318+ * A send to a non-home thread is never a delivery: "message any thread
319+ unprompted" is deliberately out of scope, and the existing
320+ `"no active T3 turn"` error stays verbatim for it.
321+ * On the home thread with no active turn, any send is a delivery.
322+ There is nothing it could belong to.
323+ * On the home thread **with** an active turn whose session key does not
324+ match, provenance must be positively established (`classify_delivery`
325+ returning certain) before the send bypasses the turn. This is the
326+ conservative half of the gate: an unattributable send in that window
327+ falls through to the turn path — a possible misplacement inside the
328+ same thread — rather than being torn out of a turn it may belong to.
329+ An unclassifiable send can therefore never steal a live answer, and a
330+ recognisable cron/lifecycle/handoff delivery never completes one.
331+ """
332+ if turn is not None and self._gateway_session_key() == turn.session_id:
333+ return False
334+ home = home_thread_id()
335+ if not home or thread_id != home:
336+ return False
337+ if turn is None:
338+ return True
339+ _kind, _label, certain = classify_delivery(
340+ content,
341+ metadata,
342+ session_user_id=self._session_user_id(),
343+ )
344+ return certain
345+
346+ async def _deliver_to_home(
347+ self,
348+ thread_id: str,
349+ content: str,
350+ metadata: dict[str, Any] | None,
351+ ) -> SendResult:
352+ """Emit one `home.deliver`, queueing it until T3 acknowledges it.
353+
354+ Deliberately touches none of the turn machinery. `_active_turns` is not
355+ read or written, no turn/item frame is emitted, and `notify` — which
356+ arrives True on every final cron delivery — is consumed only as a
357+ classification hint. A delivery landing while the user has a live turn
358+ in this same thread must leave that turn running.
359+ """
360+ kind, label, _certain = classify_delivery(
361+ content,
362+ metadata,
363+ session_user_id=self._session_user_id(),
364+ )
365+ delivery = build_delivery(
366+ thread_id=thread_id,
367+ text=str(content or ""),
368+ kind=kind,
369+ label=label,
370+ )
371+ delivery_id_value = str(delivery["deliveryId"])
372+ # Persist BEFORE sending. The queue is the durability guarantee: if the
373+ # socket dies between here and the ack, the entry survives to be
374+ # replayed on the next connect, and T3's `deliveryId` dedupe makes the
375+ # replay harmless.
376+ self._home_queue.append(delivery)
377+ try:
378+ await self._send_frame(delivery)
379+ except Exception as exc: # noqa: BLE001 - adapter send must return SendResult
380+ logger.warning(
381+ "T3 home delivery %s could not be sent (%s); it is queued for "
382+ "the next connect",
383+ delivery_id_value,
384+ exc,
385+ )
386+ # Reported successful either way: the delivery is durably queued, and
387+ # failing the send would make a cron job report an error for output
388+ # that will in fact arrive.
389+ return SendResult(success=True, message_id=delivery_id_value)
390+
391+ async def _handle_connection_accepted(self, accepted: dict[str, Any]) -> None:
392+ """Reconcile the home designation, then flush the delivery queue.
393+
394+ T3's settings blob is the authoritative designation and it republishes
395+ it on every successful handshake, so the plugin's `T3_HOME_CHANNEL` is
396+ a synced cache: a differing local value — including a hand-edited one —
397+ is overwritten. Reconciling on every accept bounds drift to a single
398+ reconnect.
399+ """
400+ thread_id = str(accepted.get("homeThreadId") or "").strip()
401+ if thread_id and thread_id != home_thread_id():
402+ logger.info("T3 designated home thread %s", thread_id)
403+ save_home_thread_id(thread_id)
404+ elif thread_id:
405+ save_home_thread_id(thread_id)
406+ await self._flush_home_queue()
407+
408+ async def _flush_home_queue(self) -> None:
409+ """Replay unacknowledged deliveries oldest-first.
410+
411+ Entries are NOT removed here — only a `home.deliver.ack` purges one.
412+ Re-sending an entry T3 already durably wrote is harmless (it dedupes on
413+ `deliveryId`); dropping one it never wrote is not.
414+ """
415+ pending = self._home_queue.entries()
416+ if not pending:
417+ return
418+ logger.info("Flushing %d queued T3 home deliver(y|ies)", len(pending))
419+ for entry in pending[:MAX_FLUSH_PER_CONNECT]:
420+ try:
421+ await self._send_frame(entry)
422+ except Exception as exc: # noqa: BLE001 - the rest rides the next connect
423+ logger.warning("T3 home delivery flush stopped: %s", exc)
424+ return
425+
426+ async def _acknowledge_home_delivery(self, message: dict[str, Any]) -> None:
427+ """Purge a delivery T3 has durably written."""
428+ delivery_id_value = str(message.get("deliveryId") or "").strip()
429+ if not delivery_id_value:
430+ raise ValueError("home.deliver.ack requires a deliveryId")
431+ self._home_queue.purge(delivery_id_value)
432+
433+ @staticmethod
434+ def _session_user_id() -> str:
435+ """Read the bound session's user id, for `/handoff` classification.
436+
437+ Returns `""` on any failure, exactly like `_gateway_session_key`.
438+ """
439+ try:
440+ from gateway.session_context import get_session_env
441+
442+ return str(get_session_env("HERMES_SESSION_USER_ID", "") or "")
443+ except Exception: # noqa: BLE001 - classification must never raise
444+ return ""
445+
268446 def format_tool_event(
269447 self, event: Any, *, mode: str = "all", preview_max_len: int = 40
270448 ) -> str | None:
@@ -410,6 +588,8 @@ class T3PlatformAdapter(BasePlatformAdapter): · 139 unmodified lines
410588 await self._describe(message)
411589 elif frame_type == "skill.body.request":
412590 await self._send_skill_body(message)
591+ elif frame_type == "home.deliver.ack":
592+ await self._acknowledge_home_delivery(message)
413593 except ValueError as exc:
414594 await self._send_frame(
415595 protocol_error(
@@ -965,19 +1145,47 @@ class T3PlatformAdapter(BasePlatformAdapter): · 549 unmodified lines
9651145 2. `HERMES_SESSION_KEY` from the Hermes session context.
9661146 3. The sole active turn, when exactly one exists — a single-threaded
9671147 Hermes process has no ambiguity to resolve, and dropping the
968− activity would be strictly worse.
1148+ activity would be strictly worse. **Cron runs are excluded from
1149+ this step** (see below).
9691150 Anything unresolved returns `None` and the item is simply not emitted;
9701151 tool activity is decorative, so this must never raise or misroute.
1152+
1153+ The cron exclusion: these hooks are process-global, so a cron job
1154+ running tools while exactly one T3 turn happens to be live would
1155+ resolve through the sole-turn fallback and paint the cron job's tool
1156+ calls into an unrelated live conversation. Cron runs are identifiable —
1157+ the scheduler builds its agent with
1158+ `session_id=f"cron_{job_id}_{timestamp}"` (`cron/scheduler.py:3017`,
1159+ passed at `:3484`), which is exactly the value these hooks receive as
1160+ `session_id`. Upstream treats the same routing hazard as real: the
1161+ scheduler deliberately clears the process-global session env vars for
1162+ it (`cron/scheduler.py:3066-3091`). A cron job's activity belongs to
1163+ the eventual `home.deliver`, never to a live turn, so it is dropped
1164+ rather than guessed at.
9711165 """
9721166 thread_id = self._thread_by_session.get(str(session_id))
9731167 if thread_id is None:
9741168 thread_id = self._thread_by_session.get(self._gateway_session_key())
9751169 if thread_id is not None:
9761170 return self._active_turns.get(thread_id)
1171+ if self._is_cron_session(session_id):
1172+ return None
9771173 if len(self._active_turns) == 1:
9781174 return next(iter(self._active_turns.values()))
9791175 return None
9801176
1177+ @staticmethod
1178+ def _is_cron_session(session_id: str) -> bool:
1179+ """True when this hook call belongs to a cron run, not a gateway turn.
1180+
1181+ Keyed on the `cron_` prefix the scheduler mints at
1182+ `cron/scheduler.py:3017`. Matching a prefix rather than an exported
1183+ constant carries the usual drift risk: if upstream renames the shape,
1184+ this degrades to today's behaviour (cron tool rows may again be
1185+ misattributed to a sole live turn) rather than breaking anything.
1186+ """
1187+ return str(session_id or "").startswith("cron_")
1188+
9811189 @staticmethod
9821190 def _gateway_session_key() -> str:
9831191 """Read the turn's gateway routing key from Hermes' session context.
@@ -1181,15 +1389,45 @@ def validate_config(config: PlatformConfig) -> bool: · 197 unmodified lines
11811389 )
11821390
11831391
1184−def env_enablement() -> dict[str, str] | None:
1392+def env_enablement() -> dict[str, Any] | None:
1393+ """Seed `PlatformConfig.extra` from the environment at config-load time.
1394+
1395+ Called by the platform registry's env-enablement hook before the adapter is
1396+ constructed, so `gateway status` and `get_connected_platforms()` reflect an
1397+ env-only enrollment without instantiating a connection.
1398+
1399+ `home_channel` is a **magic key**, not an ordinary extra: core pops it out
1400+ of the returned dict and promotes it to a real `HomeChannel` dataclass on
1401+ the `PlatformConfig` (`gateway/config.py:2648-2660`, reading only
1402+ `chat_id` / `name` / `thread_id`). That promotion is what makes
1403+ `get_home_channel("t3")` resolve, which is in turn what makes
1404+ `send_message` with a bare `t3` target, the gateway's lifecycle broadcasts,
1405+ and `/handoff t3` work at all — core hardcodes env promotion only for
1406+ built-in platforms, so a plugin must supply it here. Pattern copied from
1407+ IRC (`plugins/platforms/irc/adapter.py:653-701`).
1408+
1409+ The thread id comes from `T3_HOME_CHANNEL`, which T3 owns: the plugin
1410+ rewrites it from `homeThreadId` on every `connection.accepted`. Before the
1411+ first accept there is nothing to seed and the key is simply absent — Hermes
1412+ then behaves exactly as it did pre-home-channel, which is why the
1413+ `/sethome` nudge suppression is still needed for that window.
1414+ """
11851415 url = os.environ.get(URL_ENV, "").strip()
11861416 instance_id = os.environ.get(INSTANCE_ID_ENV, "").strip()
11871417 credential = os.environ.get(CREDENTIAL_ENV, "").strip()
11881418 if not (url and instance_id and credential):
11891419 return None
1190− return {
1420+ seed: dict[str, Any] = {
11911421 "url": url,
11921422 "instance_id": instance_id,
11931423 "credential": credential,
11941424 "nickname": os.environ.get(NICKNAME_ENV, "").strip() or "Hermes",
11951425 }
1426+ home = os.environ.get(HOME_CHANNEL_ENV, "").strip()
1427+ if home:
1428+ # T3 threads are the addressing unit end to end: `chat_id` IS the
1429+ # thread id, and the separate `thread_id` field stays unset. Setting
1430+ # both would make Hermes route `chat_id` + `thread_id` metadata at a
1431+ # platform whose `send(chat_id, ...)` already resolves the thread.
1432+ seed["home_channel"] = {"chat_id": home, "name": "Home"}
1433+ return seed
integrations/hermes-t3-gateway/connection.py+39−1
@@ -21,6 +21,8 @@ except ImportError: # pragma: no cover - Hermes currently installs websockets · 20 unmodified lines
2121
2222 MessageHandler = Callable[[dict[str, Any]], Awaitable[None]]
2323 StateHandler = Callable[[bool, str | None], Awaitable[None] | None]
24+AcceptedHandler = Callable[[dict[str, Any]], Awaitable[None]]
25+AcceptedHandler = Callable[[dict[str, Any]], Awaitable[None]]
2426
2527
2628 class ConnectionRejected(RuntimeError):
@@ -72,10 +74,12 @@ async def authenticate_socket( · 45 unmodified lines
7274 authentication: dict[str, str],
7375 hermes_version: str,
7476 timeout: float = 20,
77+ role: str = "gateway",
7578 ) -> dict[str, Any]:
7679 hello = connection_hello(
7780 hermes_version=hermes_version,
7881 authentication=authentication,
82+ role=role,
7983 )
8084 await socket.send(json.dumps(hello, separators=(",", ":"), ensure_ascii=False))
8185
@@ -165,6 +169,7 @@ class T3GatewayConnection: · 83 unmodified lines
165169 hermes_version: str,
166170 on_message: MessageHandler,
167171 on_state: StateHandler | None = None,
172+ on_accepted: AcceptedHandler | None = None,
168173 ):
169174 self.url = websocket_url(url)
170175 self.instance_id = instance_id
@@ -172,6 +177,7 @@ class T3GatewayConnection: · 1 unmodified line
172177 self.hermes_version = hermes_version
173178 self._on_message = on_message
174179 self._on_state = on_state
180+ self._on_accepted = on_accepted
175181 self._socket: Any = None
176182 self._supervisor: asyncio.Task[None] | None = None
177183 self._send_lock = asyncio.Lock()
@@ -266,7 +272,7 @@ class T3GatewayConnection: · 88 unmodified lines
266272 try:
267273 socket = await _open_socket(self.url)
268274 self._socket = socket
269− await authenticate_socket(
275+ accepted = await authenticate_socket(
270276 socket,
271277 authentication={
272278 "type": "instance-credential",
@@ -278,6 +284,10 @@ class T3GatewayConnection: · 5 unmodified lines
278284 self._connected.set()
279285 if self._first_result is not None and not self._first_result.done():
280286 self._first_result.set_result(True)
287+ # Deliberately after `_connected.set()`: the accepted callback
288+ # reconciles the home designation and flushes the durable
289+ # delivery queue, and both send frames back over this socket.
290+ await self._notify_accepted(accepted)
281291 await self._notify_state(True, None)
282292 delay = 1.0
283293 async for raw in socket:
@@ -322,6 +332,34 @@ class T3GatewayConnection: · 38 unmodified lines
322332 await asyncio.sleep(delay)
323333 delay = min(delay * 2, 30.0)
324334
335+ async def _notify_accepted(self, accepted: dict[str, Any]) -> None:
336+ """Hand the `connection.accepted` frame to the adapter, best-effort.
337+
338+ A failure here — a read-only `.env`, an unwritable queue file — must
339+ not tear down a connection that authenticated successfully, so it is
340+ logged and swallowed exactly like the state callback.
341+ """
342+ if self._on_accepted is None:
343+ return
344+ try:
345+ await self._on_accepted(accepted)
346+ except Exception: # noqa: BLE001 - reconciliation must not fail a good handshake
347+ logger.warning("T3 connection accepted callback failed", exc_info=True)
348+
349+ async def _notify_accepted(self, accepted: dict[str, Any]) -> None:
350+ """Hand the `connection.accepted` frame to the adapter, best-effort.
351+
352+ A failure here — a read-only `.env`, an unwritable queue file — must
353+ not tear down a connection that authenticated successfully, so it is
354+ logged and swallowed exactly like the state callback.
355+ """
356+ if self._on_accepted is None:
357+ return
358+ try:
359+ await self._on_accepted(accepted)
360+ except Exception: # noqa: BLE001 - reconciliation must not fail a good handshake
361+ logger.warning("T3 connection accepted callback failed", exc_info=True)
362+
325363 async def _notify_state(self, connected: bool, reason: str | None) -> None:
326364 if self._on_state is None:
327365 return
integrations/hermes-t3-gateway/home.py+605−0
@@ -0,0 +1,605 @@
1+"""Home-channel delivery: durable queue, classification, standalone sender.
2+
3+Hermes-initiated output — cron results, the agent's `send_message` tool with a
4+bare `t3` target, gateway lifecycle notices, `/handoff t3` — has no T3-issued
5+turn to stream into. It is delivered as a `home.deliver` frame against the
6+instance's durable **home thread**, whose id T3 owns and republishes on every
7+`connection.accepted`.
8+
9+Three concerns live here rather than in the adapter:
10+
11+* **The durable queue.** A delivery is written to disk before it is sent and
12+ removed only when T3 acknowledges it, so nothing is lost when either side
13+ restarts mid-flight. T3 dedupes on `deliveryId`, which is what makes
14+ re-flushing the whole queue safe.
15+* **Kind/label classification.** Best-effort provenance recovery from the send
16+ context — see `classify_delivery`.
17+* **The standalone sender.** Out-of-process cron has no live adapter, so it
18+ dials T3 itself over a short-lived `role: "delivery"` socket.
19+"""
20+
21+from __future__ import annotations
22+
23+import json
24+import logging
25+import os
26+import threading
27+from pathlib import Path
28+from typing import Any
29+
30+from .protocol import (
31+ HOME_DELIVERY_KINDS,
32+ delivery_id,
33+ home_deliver,
34+ iso_now,
35+)
36+
37+logger = logging.getLogger(__name__)
38+
39+try: # POSIX advisory locking; absent on Windows.
40+ import fcntl
41+except ImportError: # pragma: no cover - Windows fallback
42+ fcntl = None # type: ignore[assignment]
43+
44+# The env var carrying T3's home-thread designation.
45+#
46+# The name is NOT arbitrary. Hermes resolves a platform's cron home target with
47+# `_home_target_env_var` (`gateway/run.py:1541`), which falls back to
48+# `f"{PLATFORM.upper()}_HOME_CHANNEL"` for any platform without an override
49+# entry. Platform `t3` therefore resolves to exactly this string, so the
50+# `send_message` error hints, `/sethome` messaging, and cron's env-only
51+# resolution path all agree with the value the plugin writes — with no upstream
52+# override table entry required.
53+HOME_CHANNEL_ENV = "T3_HOME_CHANNEL"
54+
55+# Queue location. `gateway/` under the Hermes home is the subdirectory Hermes'
56+# own bundled plugins use for per-profile adapter state (the Discord adapter's
57+# command-sync and non-conversational stores, `plugins/platforms/discord/
58+# adapter.py:52`, `:272`, `:1694`). Using `get_hermes_home()` as the base means
59+# the queue is profile-scoped for free: a second profile with its own
60+# `HERMES_HOME` gets its own queue rather than replaying another profile's
61+# deliveries into its own home thread.
62+QUEUE_SUBDIR = "gateway"
63+QUEUE_FILENAME = "t3_home_delivery_queue.jsonl"
64+
65+# Bound the queue. Deliveries are small (a cron brief, a lifecycle line), so a
66+# few hundred is generous for any realistic outage while keeping the file
67+# readable and the flush bounded. Overflow drops the OLDEST entries: a
68+# fortnight-old cron brief is worth less than this morning's, and dropping
69+# newest would make a wedged queue permanently swallow current output.
70+MAX_QUEUE_ENTRIES = 300
71+
72+# Bound one flush attempt. A reconnect must not spend minutes replaying before
73+# the connection is usable for live traffic; the remainder rides the next
74+# reconnect.
75+MAX_FLUSH_PER_CONNECT = 50
76+
77+
78+def hermes_home() -> Path:
79+ """Resolve Hermes' home directory, degrading to the documented default.
80+
81+ Prefers Hermes' own accessor so a context-local profile override
82+ (`set_hermes_home_override`) is honoured, then `HERMES_HOME`, then the
83+ platform default. The plugin must not create state outside the active
84+ profile, but it also must not fail to queue a delivery merely because
85+ Hermes could not be imported (the standalone cron path can run in a very
86+ thin process).
87+ """
88+ try:
89+ from hermes_cli.config import get_hermes_home
90+
91+ return Path(get_hermes_home())
92+ except Exception: # noqa: BLE001 - queueing must never depend on Hermes importing
93+ pass
94+ try:
95+ from hermes_constants import get_hermes_home
96+
97+ return Path(get_hermes_home())
98+ except Exception: # noqa: BLE001 - same
99+ pass
100+ override = os.environ.get("HERMES_HOME", "").strip()
101+ return Path(override) if override else Path.home() / ".hermes"
102+
103+
104+def queue_path() -> Path:
105+ return hermes_home() / QUEUE_SUBDIR / QUEUE_FILENAME
106+
107+
108+def home_thread_id() -> str:
109+ """Read the currently designated home thread from the environment."""
110+ return os.environ.get(HOME_CHANNEL_ENV, "").strip()
111+
112+
113+def save_home_thread_id(thread_id: str) -> bool:
114+ """Persist T3's home designation, mirroring it into this process.
115+
116+ T3's settings blob is authoritative and this env var is a synced cache, so
117+ a differing local value is overwritten rather than merged — including one a
118+ user hand-edited (documented in the plugin README).
119+
120+ Returns True when the value was durably written. A read-only or managed
121+ `.env` degrades to the in-process mirror only: routing works for the life
122+ of this gateway and re-reconciles on the next connect.
123+ """
124+ value = str(thread_id or "").strip()
125+ if not value:
126+ return False
127+ saved = False
128+ try:
129+ from hermes_cli.config import save_env_value
130+
131+ save_env_value(HOME_CHANNEL_ENV, value)
132+ saved = True
133+ except Exception: # noqa: BLE001 - a read-only .env must not break the handshake
134+ logger.warning(
135+ "Could not persist %s; T3 home delivery will use the in-process "
136+ "value until the next reconnect",
137+ HOME_CHANNEL_ENV,
138+ exc_info=True,
139+ )
140+ # Mirror into this process exactly as enrollment does (`cli.py`): the
141+ # running gateway resolves the home channel from the environment and must
142+ # not need a restart to see a freshly designated thread.
143+ os.environ[HOME_CHANNEL_ENV] = value
144+ return saved
145+
146+
147+class HomeDeliveryQueue:
148+ """Append-only JSONL outbox of unacknowledged `home.deliver` frames.
149+
150+ Correctness rests on one rule: an entry is removed **only** when T3 acks
151+ its `deliveryId`. Everything else — a socket that dropped mid-send, a
152+ server that died before writing, a plugin that restarted — leaves the entry
153+ on disk to be replayed. Replay is safe because T3 dedupes on `deliveryId`,
154+ so the failure mode of this design is a duplicate suppressed server-side,
155+ never a lost delivery.
156+
157+ Two processes can hold the same queue: the gateway adapter and an
158+ out-of-process cron run using the standalone sender. Writes take a POSIX
159+ advisory lock on a sidecar file where `fcntl` is available; on Windows the
160+ in-process lock alone applies and a concurrent cron process could in
161+ principle interleave a rewrite. The consequence there is a duplicate
162+ delivery (deduped by T3), not corruption of an already-acked entry.
163+ """
164+
165+ def __init__(self, path: Path | None = None, max_entries: int = MAX_QUEUE_ENTRIES):
166+ self._path = Path(path) if path is not None else None
167+ self._max_entries = max(1, int(max_entries))
168+ self._lock = threading.RLock()
169+
170+ @property
171+ def path(self) -> Path:
172+ # Resolved lazily, not in __init__: `get_hermes_home()` honours a
173+ # context-local profile override that may be installed after the
174+ # adapter is constructed.
175+ return self._path if self._path is not None else queue_path()
176+
177+ def _ensure_parent(self) -> None:
178+ self.path.parent.mkdir(parents=True, exist_ok=True)
179+
180+ def _read_lines(self) -> list[dict[str, Any]]:
181+ path = self.path
182+ if not path.exists():
183+ return []
184+ entries: list[dict[str, Any]] = []
185+ try:
186+ raw = path.read_text(encoding="utf-8")
187+ except OSError:
188+ logger.warning("Could not read the T3 home delivery queue", exc_info=True)
189+ return []
190+ for line in raw.splitlines():
191+ line = line.strip()
192+ if not line:
193+ continue
194+ try:
195+ entry = json.loads(line)
196+ except ValueError:
197+ # A torn write from a killed process. Skip the line rather than
198+ # discarding the whole queue.
199+ continue
200+ if isinstance(entry, dict) and entry.get("deliveryId"):
201+ entries.append(entry)
202+ return entries
203+
204+ def _write_all(self, entries: list[dict[str, Any]]) -> None:
205+ self._ensure_parent()
206+ payload = "".join(
207+ json.dumps(entry, separators=(",", ":"), ensure_ascii=False) + "\n"
208+ for entry in entries
209+ )
210+ temporary = self.path.with_suffix(".jsonl.tmp")
211+ temporary.write_text(payload, encoding="utf-8")
212+ temporary.replace(self.path)
213+
214+ def _file_lock(self):
215+ """Advisory cross-process lock, or a no-op where unavailable."""
216+
217+ class _NoLock:
218+ def __enter__(self):
219+ return None
220+
221+ def __exit__(self, *args):
222+ return False
223+
224+ if fcntl is None:
225+ return _NoLock()
226+
227+ queue = self.path
228+ try:
229+ queue.parent.mkdir(parents=True, exist_ok=True)
230+ except OSError:
231+ return _NoLock()
232+
233+ class _FileLock:
234+ def __init__(self, lock_path: Path):
235+ self._lock_path = lock_path
236+ self._handle: Any = None
237+
238+ def __enter__(self):
239+ try:
240+ self._handle = open(self._lock_path, "a+") # noqa: SIM115
241+ fcntl.flock(self._handle, fcntl.LOCK_EX)
242+ except OSError:
243+ if self._handle is not None:
244+ self._handle.close()
245+ self._handle = None
246+ return None
247+
248+ def __exit__(self, *args):
249+ if self._handle is not None:
250+ try:
251+ fcntl.flock(self._handle, fcntl.LOCK_UN)
252+ finally:
253+ self._handle.close()
254+ self._handle = None
255+ return False
256+
257+ return _FileLock(queue.with_suffix(".jsonl.lock"))
258+
259+ def append(self, frame: dict[str, Any]) -> bool:
260+ """Persist one delivery. Returns False when it could not be written."""
261+ if not isinstance(frame, dict) or not frame.get("deliveryId"):
262+ return False
263+ with self._lock, self._file_lock():
264+ entries = self._read_lines()
265+ if any(
266+ entry.get("deliveryId") == frame["deliveryId"] for entry in entries
267+ ):
268+ return True
269+ entries.append(dict(frame))
270+ dropped = len(entries) - self._max_entries
271+ if dropped > 0:
272+ logger.warning(
273+ "T3 home delivery queue is full (%d entries); dropping the "
274+ "%d oldest undelivered %s",
275+ self._max_entries,
276+ dropped,
277+ "delivery" if dropped == 1 else "deliveries",
278+ )
279+ entries = entries[dropped:]
280+ try:
281+ self._write_all(entries)
282+ except OSError:
283+ logger.error(
284+ "Could not persist a T3 home delivery to %s",
285+ self.path,
286+ exc_info=True,
287+ )
288+ return False
289+ return True
290+
291+ def entries(self) -> list[dict[str, Any]]:
292+ """Every unacknowledged delivery, oldest first."""
293+ with self._lock:
294+ return self._read_lines()
295+
296+ def purge(self, delivery_id_value: str) -> bool:
297+ """Remove one acknowledged delivery. Returns True when it was present."""
298+ target = str(delivery_id_value or "").strip()
299+ if not target:
300+ return False
301+ with self._lock, self._file_lock():
302+ entries = self._read_lines()
303+ remaining = [
304+ entry for entry in entries if entry.get("deliveryId") != target
305+ ]
306+ if len(remaining) == len(entries):
307+ return False
308+ try:
309+ self._write_all(remaining)
310+ except OSError:
311+ logger.error(
312+ "Could not purge acknowledged T3 home delivery %s from %s",
313+ target,
314+ self.path,
315+ exc_info=True,
316+ )
317+ return False
318+ return True
319+
320+ def __len__(self) -> int:
321+ return len(self.entries())
322+
323+
324+# ── classification ────────────────────────────────────────────────────────
325+
326+# Hermes' lifecycle broadcasts, matched by their literal shapes at 62e07223.
327+# These are inline f-strings upstream, not exported constants, so the same
328+# drift risk documented for the `/sethome` notice applies: a wording change
329+# upstream downgrades these to `kind: "message"`, which costs a badge and a
330+# quiet-delivery classification, never the delivery itself.
331+_LIFECYCLE_EXACT = frozenset(
332+ {
333+ # gateway/run.py:17277
334+ "♻️ Gateway online — Hermes is back and ready.",
335+ # gateway/run.py:17236
336+ "♻ Gateway restarted successfully. Your session continues.",
337+ }
338+)
339+_LIFECYCLE_PREFIXES = (
340+ # gateway/run.py:6599 — f"⚠️ Gateway {action} — {hint}"
341+ "⚠️ Gateway restarting — ",
342+ "⚠️ Gateway shutting down — ",
343+)
344+
345+# cron/scheduler.py:1513 builds this header when `cron.wrap_response` is on
346+# (the default).
347+_CRON_HEADER = "Cronjob Response: "
348+
349+# gateway/run.py:8854 — the handoff destination's synthetic source identity.
350+_HANDOFF_USER_ID = "system:handoff"
351+
352+
353+def classify_delivery(
354+ content: str,
355+ metadata: dict[str, Any] | None = None,
356+ *,
357+ session_user_id: str = "",
358+) -> tuple[str, str, bool]:
359+ """Best-effort `(kind, label, provenance_certain)` for a proactive send.
360+
361+ Hermes' `adapter.send()` contract carries no structured "this is a cron
362+ delivery" marker on every path, so this reads the signals that do exist:
363+
364+ * **`metadata["job_id"]`** — the cron scheduler stamps it into the routed
365+ metadata for every live-gateway delivery (`cron/scheduler.py:1782`), and
366+ `DeliveryRouter._deliver_to_platform` passes that dict through to
367+ `adapter.send` unchanged (`gateway/delivery.py:606`). This is the
368+ strongest signal available and the only structured one.
369+ * **The cron wrap header** — `cron.wrap_response` (default on) prefixes the
370+ brief with `Cronjob Response: <name>` (`cron/scheduler.py:1513`), which
371+ also supplies a human job name for the badge.
372+ * **Lifecycle literals** — the gateway's online/restart/shutdown notices.
373+ * **The bound session identity** — `/handoff` dispatches its synthetic turn
374+ under `user_id="system:handoff"` (`gateway/run.py:8854`), bound onto the
375+ session context by `_set_session_env` (`gateway/run.py:17372`).
376+
377+ The third element reports whether provenance was *positively* established.
378+ The adapter uses it as the tiebreaker when a live turn exists in the home
379+ thread: only a positively-identified proactive send may bypass that turn,
380+ so an unclassifiable send can never steal a user's answer.
381+
382+ Worst case for the first two elements is a wrong badge, never a lost
383+ delivery — an unrecognised send is `("message", "Hermes", False)`.
384+ """
385+ text = str(content or "")
386+ meta = metadata or {}
387+
388+ job_id = str(meta.get("job_id") or "").strip()
389+ job_name = _cron_job_name(text)
390+ if job_id or job_name:
391+ label = f"Cron: {job_name or job_id}"
392+ return "cron", label, True
393+
394+ stripped = text.strip()
395+ if stripped in _LIFECYCLE_EXACT or stripped.startswith(_LIFECYCLE_PREFIXES):
396+ return "lifecycle", "Gateway", True
397+
398+ if str(session_user_id or "").strip() == _HANDOFF_USER_ID:
399+ return "handoff", "Handoff", True
400+
401+ return "message", "Hermes", False
402+
403+
404+def _cron_job_name(text: str) -> str:
405+ """Recover the job name from the cron wrap header, if present."""
406+ first_line = text.lstrip().split("\n", 1)[0]
407+ if not first_line.startswith(_CRON_HEADER):
408+ return ""
409+ return first_line[len(_CRON_HEADER) :].strip()
410+
411+
412+def build_delivery(
413+ *,
414+ thread_id: str,
415+ text: str,
416+ kind: str = "message",
417+ label: str = "Hermes",
418+ created_at: str | None = None,
419+) -> dict[str, Any]:
420+ """Mint one `home.deliver` frame, id and timestamp included.
421+
422+ `createdAt` is stamped here — when Hermes produced the content — not when
423+ the frame reaches T3, so a delivery flushed after a two-hour outage still
424+ reports the moment it was written.
425+ """
426+ normalized_kind = kind if kind in HOME_DELIVERY_KINDS else "other"
427+ return home_deliver(
428+ delivery_id_value=delivery_id(),
429+ thread_id=thread_id,
430+ kind=normalized_kind,
431+ label=label,
432+ text=text,
433+ created_at=created_at or iso_now(),
434+ )
435+
436+
437+# ── standalone (out-of-process) sender ────────────────────────────────────
438+
439+
440+async def standalone_send(
441+ pconfig: Any,
442+ chat_id: str,
443+ message: str,
444+ *,
445+ thread_id: str | None = None,
446+ media_files: list[str] | None = None,
447+ force_document: bool = False,
448+) -> dict[str, Any]:
449+ """Deliver to the home thread with no live gateway adapter in this process.
450+
451+ Registered as `standalone_sender_fn` and called by
452+ `tools/send_message_tool._send_via_adapter` when the in-process adapter
453+ weakref is empty — the `hermes cron` process running separately from
454+ `hermes gateway`. Without it, `deliver=t3` cron jobs fail with "No live
455+ adapter for platform".
456+
457+ The socket announces `role: "delivery"`. That is load-bearing: T3's broker
458+ registers a `gateway` connection under generation fencing and displaces the
459+ previous one, so a naive cron dial-in would kick the live gateway socket
460+ off its own instance mid-turn. A `delivery` connection is authenticated
461+ identically, never becomes the primary, and is expected to close promptly.
462+
463+ A connection failure is **not** a cron failure. The delivery is already on
464+ disk before the socket is opened, so the job reports success-with-queued
465+ and the live gateway flushes it on its next `connection.accepted`.
466+
467+ `media_files` and `force_document` are accepted for signature parity
468+ (`gateway/platform_registry.py:150-158`); the v3 wire contract carries no
469+ attachment framing, so they are ignored.
470+ """
471+ del force_document
472+ if media_files:
473+ logger.info(
474+ "T3 standalone send ignoring %d media file(s): the v3 gateway "
475+ "contract has no attachment framing",
476+ len(media_files),
477+ )
478+
479+ extra = getattr(pconfig, "extra", None) or {}
480+
481+ def _setting(key: str, env: str) -> str:
482+ return str(extra.get(key) or os.environ.get(env, "") or "").strip()
483+
484+ url = _setting("url", "HERMES_T3_GATEWAY_URL")
485+ instance_id = _setting("instance_id", "HERMES_T3_GATEWAY_INSTANCE_ID")
486+ credential = _setting("credential", "HERMES_T3_GATEWAY_CREDENTIAL")
487+ if not (url and instance_id and credential):
488+ return {
489+ "error": (
490+ "T3 standalone send: this Hermes is not enrolled. Run "
491+ "`hermes t3 connect --url <url> --token <token>` first."
492+ )
493+ }
494+
495+ target = str(chat_id or "").strip() or str(thread_id or "").strip()
496+ if not target:
497+ target = home_thread_id()
498+ if not target:
499+ return {
500+ "error": (
501+ "T3 standalone send: no home thread is designated. Start "
502+ "`hermes gateway` once so T3 can publish one."
503+ )
504+ }
505+
506+ kind, label, _certain = classify_delivery(message, None)
507+ frame = build_delivery(thread_id=target, text=message, kind=kind, label=label)
508+ delivery = str(frame["deliveryId"])
509+
510+ queue = HomeDeliveryQueue()
511+ queued = queue.append(frame)
512+
513+ try:
514+ acked = await _deliver_over_short_lived_socket(
515+ url=url,
516+ instance_id=instance_id,
517+ credential=credential,
518+ frame=frame,
519+ )
520+ except Exception as exc: # noqa: BLE001 - cron delivery must not raise
521+ logger.debug("T3 standalone send raised", exc_info=True)
522+ if queued:
523+ return {
524+ "success": True,
525+ "message_id": delivery,
526+ "queued": True,
527+ "detail": f"T3 unreachable ({exc}); queued for the next connect",
528+ }
529+ return {"error": f"T3 standalone send failed: {exc}"}
530+
531+ if acked:
532+ queue.purge(delivery)
533+ return {"success": True, "message_id": delivery}
534+ if queued:
535+ return {
536+ "success": True,
537+ "message_id": delivery,
538+ "queued": True,
539+ "detail": "T3 did not acknowledge the delivery; queued for retry",
540+ }
541+ return {"error": "T3 standalone send: the delivery was neither acked nor queued"}
542+
543+
544+async def _deliver_over_short_lived_socket(
545+ *,
546+ url: str,
547+ instance_id: str,
548+ credential: str,
549+ frame: dict[str, Any],
550+ timeout: float = 20.0,
551+) -> bool:
552+ """Open, authenticate as `delivery`, send, await the ack, close."""
553+ import asyncio
554+
555+ from .connection import _open_socket, authenticate_socket
556+
557+ hermes_version = _hermes_version()
558+ socket = await _open_socket(url)
559+ try:
560+ await authenticate_socket(
561+ socket,
562+ authentication={
563+ "type": "instance-credential",
564+ "instanceId": instance_id,
565+ "credential": credential,
566+ },
567+ hermes_version=hermes_version,
568+ role="delivery",
569+ )
570+ await socket.send(
571+ json.dumps(frame, separators=(",", ":"), ensure_ascii=False)
572+ )
573+ loop = asyncio.get_running_loop()
574+ deadline = loop.time() + timeout
575+ while True:
576+ remaining = deadline - loop.time()
577+ if remaining <= 0:
578+ return False
579+ raw = await asyncio.wait_for(socket.recv(), timeout=remaining)
580+ try:
581+ reply = json.loads(raw)
582+ except ValueError:
583+ continue
584+ if not isinstance(reply, dict):
585+ continue
586+ if reply.get("type") == "home.deliver.ack" and str(
587+ reply.get("deliveryId") or ""
588+ ) == str(frame["deliveryId"]):
589+ return True
590+ # Anything else (a liveness ping, an unrelated frame) is ignored:
591+ # this socket exists only to hand over one delivery.
592+ finally:
593+ try:
594+ await socket.close()
595+ except Exception: # noqa: BLE001 - a failed close must not fail the send
596+ logger.debug("T3 standalone socket close failed", exc_info=True)
597+
598+
599+def _hermes_version() -> str:
600+ try:
601+ from hermes_cli import __version__
602+
603+ return str(__version__)
604+ except Exception: # noqa: BLE001 - version discovery must not block delivery
605+ return "unknown"
integrations/hermes-t3-gateway/plugin.yaml+1−1
@@ -1,7 +1,7 @@
11 name: hermes-t3-gateway
22 label: T3 Code
33 kind: platform
4−version: 0.2.0
4+version: 0.3.0
55 description: >
66 Experimental outbound gateway that exposes one Hermes Agent process as a
77 named T3 Code provider instance over an authenticated WebSocket.
integrations/hermes-t3-gateway/protocol.py+141−2
@@ -7,10 +7,17 @@ import uuid · 6 unmodified lines
77 from datetime import datetime, timezone
88 from typing import Any
99
10−PROTOCOL_VERSION = 2
11−PLUGIN_VERSION = "0.2.0"
10+PROTOCOL_VERSION = 3
11+PLUGIN_VERSION = "0.3.0"
1212 WEBSOCKET_PATH = "/api/hermes-gateway/ws"
1313
14+# What a connecting socket intends to be. `gateway` is the instance's one live
15+# plugin connection; `delivery` is a short-lived socket (an out-of-process cron
16+# run) that hands over a `home.deliver` and leaves. T3 never registers a
17+# `delivery` socket as the primary connection, so it cannot displace a healthy
18+# gateway connection under the broker's generation fencing.
19+CONNECTION_ROLES = frozenset({"gateway", "delivery"})
20+
1421 CAPABILITIES = {
1522 "protocolVersion": PROTOCOL_VERSION,
1623 "streaming": True,
@@ -34,9 +41,21 @@ SERVER_COMMANDS = frozenset( · 17 unmodified lines
3441 "ping",
3542 "describe.request",
3643 "skill.body.request",
44+ "home.deliver.ack",
3745 }
3846 )
3947
48+# Kinds a `home.deliver` may carry. Mirrors the T3 contract's
49+# `HermesGatewayHomeDeliveryKind`; anything not classified lands on "message".
50+HOME_DELIVERY_KINDS = frozenset({"cron", "message", "lifecycle", "handoff", "other"})
51+
52+# Wire bounds from the T3 contract (`HermesGatewayHomeDeliver`): `label` is a
53+# trimmed non-empty string of at most 200 chars, `text` is 1..120000 chars.
54+# Enforced here so a pathological Hermes payload is clamped rather than
55+# rejected by the server after the plugin already dropped its local copy.
56+MAX_HOME_DELIVERY_LABEL_CHARS = 200
57+MAX_HOME_DELIVERY_TEXT_CHARS = 120_000
58+
4059 # Ceiling on a single skill body crossing the wire. Skill markdown is
4160 # human-authored documentation, not data: 512 KiB is far past any real
4261 # SKILL.md while still bounding a pathological file from stalling the socket.
@@ -51,6 +70,26 @@ def item_id() -> str: · 8 unmodified lines
5170 return str(uuid.uuid4())
5271
5372
73+def delivery_id() -> str:
74+ """Mint the idempotency key for one home delivery.
75+
76+ Stable across retries by construction: the id is minted once, when the
77+ delivery is created, and the queued copy carries it verbatim through every
78+ flush. T3 dedupes on it, which is what makes double-flushing safe.
79+ """
80+ return str(uuid.uuid4())
81+
82+
83+def delivery_id() -> str:
84+ """Mint the idempotency key for one home delivery.
85+
86+ Stable across retries by construction: the id is minted once, when the
87+ delivery is created, and the queued copy carries it verbatim through every
88+ flush. T3 dedupes on it, which is what makes double-flushing safe.
89+ """
90+ return str(uuid.uuid4())
91+
92+
5493 def iso_now() -> str:
5594 return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
5695
@@ -261,8 +300,19 @@ def connection_hello( · 204 unmodified lines
261300 authentication: dict[str, str],
262301 hello_request_id: str | None = None,
263302 model: str | None = None,
303+ role: str = "gateway",
264304 ) -> dict[str, Any]:
305+ """Build the handshake frame.
306+
307+ `role` is sent explicitly even though the T3 contract decodes a missing
308+ value as `"gateway"`: an out-of-process cron sender MUST announce
309+ `"delivery"` or the broker registers it as the instance's primary
310+ connection and generation-fences the live gateway socket off.
311+ """
265312 resolved_model = model if model is not None else configured_model()
313+ normalized_role = str(role or "gateway").strip().lower()
314+ if normalized_role not in CONNECTION_ROLES:
315+ normalized_role = "gateway"
266316 hello: dict[str, Any] = {
267317 "type": "connection.hello",
268318 "requestId": hello_request_id or request_id(),
@@ -271,6 +321,7 @@ def connection_hello( · 2 unmodified lines
271321 "hermesVersion": hermes_version,
272322 "capabilities": dict(CAPABILITIES),
273323 "authentication": authentication,
324+ "role": normalized_role,
274325 }
275326 # Optional on the wire: omit entirely rather than send null/empty so a
276327 # server that has the field still falls back to its generic label.
@@ -279,6 +330,94 @@ def connection_hello( · 2 unmodified lines
279330 return hello
280331
281332
333+def home_deliver(
334+ *,
335+ delivery_id_value: str,
336+ thread_id: str,
337+ kind: str,
338+ label: str,
339+ text: str,
340+ created_at: str | None = None,
341+) -> dict[str, Any]:
342+ """Build a `home.deliver` frame with every wire bound already applied.
343+
344+ Like `protocol_error`, this wraps the generic `frame()` helper rather than
345+ assembling the envelope itself — but unlike the plain outbound frames the
346+ adapter builds inline, a delivery has server-side validation the plugin
347+ must not trip: `label` is trimmed non-empty ≤200 chars and `text` is
348+ 1..120000 chars in the T3 contract. Normalizing here rather than at the
349+ call sites means a queued delivery is already wire-valid on disk, so a
350+ flush after a plugin upgrade cannot resurrect a payload the server will
351+ reject — the plugin would purge it only on an ack that never comes.
352+
353+ An unknown `kind` degrades to `"other"` and an empty label to `"Hermes"`:
354+ a misclassified badge is the documented worst case, a dropped delivery is
355+ not.
356+ """
357+ normalized_kind = str(kind or "").strip().lower()
358+ if normalized_kind not in HOME_DELIVERY_KINDS:
359+ normalized_kind = "other"
360+ normalized_label = str(label or "").strip()[:MAX_HOME_DELIVERY_LABEL_CHARS].strip()
361+ if not normalized_label:
362+ normalized_label = "Hermes"
363+ normalized_text = str(text or "")[:MAX_HOME_DELIVERY_TEXT_CHARS]
364+ if not normalized_text:
365+ normalized_text = " "
366+ return frame(
367+ "home.deliver",
368+ deliveryId=delivery_id_value,
369+ threadId=str(thread_id),
370+ kind=normalized_kind,
371+ label=normalized_label,
372+ text=normalized_text,
373+ createdAt=created_at or iso_now(),
374+ )
375+
376+
377+def home_deliver(
378+ *,
379+ delivery_id_value: str,
380+ thread_id: str,
381+ kind: str,
382+ label: str,
383+ text: str,
384+ created_at: str | None = None,
385+) -> dict[str, Any]:
386+ """Build a `home.deliver` frame with every wire bound already applied.
387+
388+ Like `protocol_error`, this wraps the generic `frame()` helper rather than
389+ assembling the envelope itself — but unlike the plain outbound frames the
390+ adapter builds inline, a delivery has server-side validation the plugin
391+ must not trip: `label` is trimmed non-empty <=200 chars and `text` is
392+ 1..120000 chars in the T3 contract. Normalizing here rather than at the
393+ call sites means a queued delivery is already wire-valid on disk, so a
394+ flush after a plugin upgrade cannot resurrect a payload the server will
395+ reject — the plugin would purge it only on an ack that never comes.
396+
397+ An unknown `kind` degrades to `"other"` and an empty label to `"Hermes"`:
398+ a misclassified badge is the documented worst case, a dropped delivery is
399+ not.
400+ """
401+ normalized_kind = str(kind or "").strip().lower()
402+ if normalized_kind not in HOME_DELIVERY_KINDS:
403+ normalized_kind = "other"
404+ normalized_label = str(label or "").strip()[:MAX_HOME_DELIVERY_LABEL_CHARS].strip()
405+ if not normalized_label:
406+ normalized_label = "Hermes"
407+ normalized_text = str(text or "")[:MAX_HOME_DELIVERY_TEXT_CHARS]
408+ if not normalized_text:
409+ normalized_text = " "
410+ return frame(
411+ "home.deliver",
412+ deliveryId=delivery_id_value,
413+ threadId=str(thread_id),
414+ kind=normalized_kind,
415+ label=normalized_label,
416+ text=normalized_text,
417+ createdAt=created_at or iso_now(),
418+ )
419+
420+
282421 def protocol_error(
283422 code: str,
284423 message: str,
integrations/hermes-t3-gateway/tests/test_adapter.py+466−28
@@ -7,6 +7,7 @@ import enum · 6 unmodified lines
77 import importlib.util
88 import pathlib
99 import sys
10+import tempfile
1011 import types
1112 import unittest
1213 import unittest.mock
@@ -146,7 +147,7 @@ def load_plugin_modules(): · 133 unmodified lines
146147 package = types.ModuleType(PACKAGE)
147148 package.__path__ = [str(ROOT)]
148149 sys.modules[PACKAGE] = package
149− for name in ("protocol", "connection", "cli", "adapter"):
150+ for name in ("protocol", "connection", "cli", "home", "adapter"):
150151 spec = importlib.util.spec_from_file_location(
151152 f"{PACKAGE}.{name}", ROOT / f"{name}.py"
152153 )
@@ -159,6 +160,7 @@ def load_plugin_modules(): · 6 unmodified lines
159160
160161 adapter_module = load_plugin_modules()
161162 protocol_module = sys.modules[f"{PACKAGE}.protocol"]
163+home_module = sys.modules[f"{PACKAGE}.home"]
162164
163165
164166 @contextlib.contextmanager
@@ -217,7 +219,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 52 unmodified lines
217219 await self.adapter._handle_server_frame(
218220 {
219221 "type": "session.ensure",
220− "protocolVersion": 2,
222+ "protocolVersion": 3,
221223 "requestId": f"ensure-{thread_id}",
222224 "threadId": thread_id,
223225 }
@@ -226,7 +228,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines
226228 await self.adapter._handle_server_frame(
227229 {
228230 "type": "turn.start",
229− "protocolVersion": 2,
231+ "protocolVersion": 3,
230232 "requestId": f"start-{thread_id}",
231233 "threadId": thread_id,
232234 "sessionId": session_id,
@@ -240,7 +242,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 7 unmodified lines
240242 await self.adapter._handle_server_frame(
241243 {
242244 "type": "session.ensure",
243− "protocolVersion": 2,
245+ "protocolVersion": 3,
244246 "requestId": "ensure-1",
245247 "threadId": "thread-1",
246248 }
@@ -253,7 +255,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 6 unmodified lines
253255 await self.adapter._handle_server_frame(
254256 {
255257 "type": "turn.start",
256− "protocolVersion": 2,
258+ "protocolVersion": 3,
257259 "requestId": "start-1",
258260 "threadId": "thread-1",
259261 "sessionId": ready["sessionId"],
@@ -833,7 +835,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 573 unmodified lines
833835 await self.adapter._handle_server_frame(
834836 {
835837 "type": "session.ensure",
836− "protocolVersion": 2,
838+ "protocolVersion": 3,
837839 "requestId": "ensure-reconnect",
838840 "threadId": "thread-reconnect",
839841 "resumeSessionId": session_id,
@@ -849,7 +851,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 9 unmodified lines
849851 await self.adapter._handle_server_frame(
850852 {
851853 "type": "session.ensure",
852− "protocolVersion": 2,
854+ "protocolVersion": 3,
853855 "requestId": "ensure-2",
854856 "threadId": "thread-2",
855857 }
@@ -858,7 +860,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines
858860 await self.adapter._handle_server_frame(
859861 {
860862 "type": "turn.start",
861− "protocolVersion": 2,
863+ "protocolVersion": 3,
862864 "requestId": "start-2",
863865 "threadId": "thread-2",
864866 "sessionId": session_id,
@@ -877,7 +879,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 12 unmodified lines
877879 await self.adapter._handle_server_frame(
878880 {
879881 "type": "turn.steer",
880− "protocolVersion": 2,
882+ "protocolVersion": 3,
881883 "requestId": "steer-2",
882884 "threadId": "thread-2",
883885 "sessionId": session_id,
@@ -949,7 +951,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 65 unmodified lines
949951 await self.adapter._handle_server_frame(
950952 {
951953 "type": "turn.steer",
952− "protocolVersion": 2,
954+ "protocolVersion": 3,
953955 "requestId": "steer-race",
954956 "threadId": "thread-steer-race",
955957 "sessionId": session_id,
@@ -998,7 +1000,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 42 unmodified lines
9981000 await self.adapter._handle_server_frame(
9991001 {
10001002 "type": "turn.steer",
1001− "protocolVersion": 2,
1003+ "protocolVersion": 3,
10021004 "requestId": "steer-edit",
10031005 "threadId": "thread-steer-edit",
10041006 "sessionId": session_id,
@@ -1017,7 +1019,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 12 unmodified lines
10171019 await self.adapter._handle_server_frame(
10181020 {
10191021 "type": "session.ensure",
1020− "protocolVersion": 2,
1022+ "protocolVersion": 3,
10211023 "requestId": "ensure-rejected-steer",
10221024 "threadId": "thread-rejected-steer",
10231025 }
@@ -1026,7 +1028,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines
10261028 await self.adapter._handle_server_frame(
10271029 {
10281030 "type": "turn.start",
1029− "protocolVersion": 2,
1031+ "protocolVersion": 3,
10301032 "requestId": "start-rejected-steer",
10311033 "threadId": "thread-rejected-steer",
10321034 "sessionId": session_id,
@@ -1043,7 +1045,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines
10431045 await self.adapter._handle_server_frame(
10441046 {
10451047 "type": "turn.steer",
1046− "protocolVersion": 2,
1048+ "protocolVersion": 3,
10471049 "requestId": "steer-rejected",
10481050 "threadId": "thread-rejected-steer",
10491051 "sessionId": session_id,
@@ -1090,7 +1092,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 40 unmodified lines
10901092 await self.adapter._handle_server_frame(
10911093 {
10921094 "type": "session.ensure",
1093− "protocolVersion": 2,
1095+ "protocolVersion": 3,
10941096 "requestId": "ensure-failed-steer",
10951097 "threadId": "thread-failed-steer",
10961098 }
@@ -1099,7 +1101,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines
10991101 await self.adapter._handle_server_frame(
11001102 {
11011103 "type": "turn.start",
1102− "protocolVersion": 2,
1104+ "protocolVersion": 3,
11031105 "requestId": "start-failed-steer",
11041106 "threadId": "thread-failed-steer",
11051107 "sessionId": session_id,
@@ -1116,7 +1118,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines
11161118 await self.adapter._handle_server_frame(
11171119 {
11181120 "type": "turn.steer",
1119− "protocolVersion": 2,
1121+ "protocolVersion": 3,
11201122 "requestId": "steer-failed",
11211123 "threadId": "thread-failed-steer",
11221124 "sessionId": session_id,
@@ -1204,7 +1206,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 81 unmodified lines
12041206 await self.adapter._handle_server_frame(
12051207 {
12061208 "type": "session.ensure",
1207− "protocolVersion": 2,
1209+ "protocolVersion": 3,
12081210 "requestId": "ensure-3",
12091211 "threadId": "thread-3",
12101212 }
@@ -1214,7 +1216,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 3 unmodified lines
12141216 await self.adapter._handle_server_frame(
12151217 {
12161218 "type": "session.stop",
1217− "protocolVersion": 2,
1219+ "protocolVersion": 3,
12181220 "requestId": "stop-3",
12191221 "threadId": "thread-3",
12201222 "sessionId": session_id,
@@ -1235,7 +1237,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 14 unmodified lines
12351237 await self.adapter._handle_server_frame(
12361238 {
12371239 "type": "describe.request",
1238− "protocolVersion": 2,
1240+ "protocolVersion": 3,
12391241 "requestId": "describe-1",
12401242 }
12411243 )
@@ -1244,7 +1246,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines
12441246 self.assertEqual(reply["type"], "describe.response")
12451247 # Correlation, exactly like ping -> pong.
12461248 self.assertEqual(reply["requestId"], "describe-1")
1247− self.assertEqual(reply["protocolVersion"], 2)
1249+ self.assertEqual(reply["protocolVersion"], 3)
12481250 self.assertEqual(reply["hermesVersion"], "0.19.0")
12491251 self.assertIsInstance(reply["skills"], list)
12501252 self.assertIn("capabilities", reply)
@@ -1258,7 +1260,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 7 unmodified lines
12581260 await self.adapter._handle_server_frame(
12591261 {
12601262 "type": "describe.request",
1261− "protocolVersion": 2,
1263+ "protocolVersion": 3,
12621264 "requestId": "describe-degraded",
12631265 }
12641266 )
@@ -1275,7 +1277,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines
12751277 await self.adapter._handle_server_frame(
12761278 {
12771279 "type": "skill.body.request",
1278− "protocolVersion": 2,
1280+ "protocolVersion": 3,
12791281 "requestId": "body-degraded",
12801282 "skillName": "codex",
12811283 }
@@ -1293,7 +1295,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 11 unmodified lines
12931295 await self.adapter._handle_server_frame(
12941296 {
12951297 "type": "skill.body.request",
1296− "protocolVersion": 2,
1298+ "protocolVersion": 3,
12971299 "requestId": "body-1",
12981300 "skillName": "codex",
12991301 }
@@ -1310,7 +1312,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines
13101312 await self.adapter._handle_server_frame(
13111313 {
13121314 "type": "skill.body.request",
1313− "protocolVersion": 2,
1315+ "protocolVersion": 3,
13141316 "requestId": "body-2",
13151317 "skillName": "does-not-exist",
13161318 }
@@ -1330,7 +1332,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 13 unmodified lines
13301332 await self.adapter._handle_server_frame(
13311333 {
13321334 "type": "skill.body.request",
1333− "protocolVersion": 2,
1335+ "protocolVersion": 3,
13341336 "requestId": "body-3",
13351337 }
13361338 )
@@ -1344,12 +1346,12 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 7 unmodified lines
13441346 for message in (
13451347 {
13461348 "type": "describe.request",
1347− "protocolVersion": 2,
1349+ "protocolVersion": 3,
13481350 "requestId": "describe-no-error",
13491351 },
13501352 {
13511353 "type": "skill.body.request",
1352− "protocolVersion": 2,
1354+ "protocolVersion": 3,
13531355 "requestId": "body-no-error",
13541356 "skillName": "codex",
13551357 },
@@ -1535,6 +1537,442 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 179 unmodified lines
15351537 types_after = [m["type"] for m in self.connection.messages[order_start:]]
15361538 self.assertEqual(types_after[-2:], ["turn.completed", "connection.status"])
15371539
1540+ async def test_cron_tool_hooks_are_excluded_from_the_sole_turn_fallback(self):
1541+ """A cron job's tool calls must never land in an unrelated live turn.
1542+
1543+ The hooks are process-global, so a cron job running tools while exactly
1544+ one T3 turn happens to be active would otherwise resolve through the
1545+ sole-active-turn fallback and paint its tool rows into a conversation
1546+ it has nothing to do with. Cron runs are identifiable by the
1547+ `cron_<job>_<timestamp>` session id the scheduler mints
1548+ (`cron/scheduler.py:3017`); their activity belongs to the eventual
1549+ `home.deliver`, not to any turn.
1550+ """
1551+ self.adapter._event_loop = asyncio.get_running_loop()
1552+ await self._start_turn("thread-live", "turn-live")
1553+ frames_before = len(self.connection.messages)
1554+ cron_session = "cron_daily-digest_20260726_090000"
1555+
1556+ with unittest.mock.patch.object(
1557+ adapter_module.T3PlatformAdapter,
1558+ "_gateway_session_key",
1559+ staticmethod(lambda: ""),
1560+ ):
1561+ self.adapter.emit_tool_started(
1562+ cron_session, "web_search", {"query": "weather"}, "cron-call-1"
1563+ )
1564+ self.adapter.emit_tool_completed(
1565+ cron_session, "web_search", "sunny", 12, "cron-call-1"
1566+ )
1567+ await asyncio.sleep(0)
1568+
1569+ self.assertEqual(self.connection.messages[frames_before:], [])
1570+ # The unrelated turn is untouched and still streaming.
1571+ self.assertIn("thread-live", self.adapter._active_turns)
1572+
1573+ # A genuine gateway run id still takes the fallback — the exclusion is
1574+ # scoped to cron, not a blanket removal of the fallback.
1575+ with unittest.mock.patch.object(
1576+ adapter_module.T3PlatformAdapter,
1577+ "_gateway_session_key",
1578+ staticmethod(lambda: ""),
1579+ ):
1580+ self.adapter.emit_tool_started(
1581+ "20260726_090000_ab12cd34", "read_file", {"path": "a.py"}, "call-9"
1582+ )
1583+ await asyncio.sleep(0)
1584+ fallback_frames = self.connection.messages[frames_before:]
1585+ self.assertEqual([m["type"] for m in fallback_frames], ["item.started"])
1586+ self.assertEqual(fallback_frames[0]["threadId"], "thread-live")
1587+
1588+
1589+class HomeDeliveryTests(unittest.IsolatedAsyncioTestCase):
1590+ """The proactive `home.deliver` branch and its durable queue."""
1591+
1592+ HOME = "home-thread"
1593+
1594+ async def asyncSetUp(self):
1595+ self.adapter = adapter_module.T3PlatformAdapter(
1596+ PlatformConfig(
1597+ extra={
1598+ "url": "wss://t3.example/api/hermes-gateway/ws",
1599+ "instance_id": "instance",
1600+ "credential": "credential",
1601+ }
1602+ )
1603+ )
1604+ self.connection = FakeConnection()
1605+ self.adapter._connection = self.connection
1606+
1607+ self._tmp = tempfile.TemporaryDirectory()
1608+ self.addCleanup(self._tmp.cleanup)
1609+ queue_file = pathlib.Path(self._tmp.name) / "gateway" / "queue.jsonl"
1610+ self.queue = home_module.HomeDeliveryQueue(path=queue_file)
1611+ self.adapter._home_queue = self.queue
1612+
1613+ environment = unittest.mock.patch.dict(
1614+ adapter_module.os.environ,
1615+ {home_module.HOME_CHANNEL_ENV: self.HOME},
1616+ )
1617+ environment.start()
1618+ self.addCleanup(environment.stop)
1619+
1620+ # No Hermes session context is bound in tests, so the real accessors
1621+ # would fall through to os.environ. Pin them to "no session" — the
1622+ # state a cron run or a lifecycle broadcast is actually in.
1623+ for name in ("_gateway_session_key", "_session_user_id"):
1624+ patch = unittest.mock.patch.object(
1625+ adapter_module.T3PlatformAdapter, name, staticmethod(lambda: "")
1626+ )
1627+ patch.start()
1628+ self.addCleanup(patch.stop)
1629+
1630+ async def _start_turn(self, thread_id: str, turn_id: str) -> str:
1631+ await self.adapter._handle_server_frame(
1632+ {
1633+ "type": "session.ensure",
1634+ "protocolVersion": 3,
1635+ "requestId": f"ensure-{thread_id}",
1636+ "threadId": thread_id,
1637+ }
1638+ )
1639+ session_id = self.adapter._sessions[thread_id]
1640+ await self.adapter._handle_server_frame(
1641+ {
1642+ "type": "turn.start",
1643+ "protocolVersion": 3,
1644+ "requestId": f"start-{thread_id}",
1645+ "threadId": thread_id,
1646+ "sessionId": session_id,
1647+ "turnId": turn_id,
1648+ "text": "Start",
1649+ }
1650+ )
1651+ return session_id
1652+
1653+ async def test_a_proactive_send_to_home_emits_home_deliver(self):
1654+ frames_before = len(self.connection.messages)
1655+
1656+ result = await self.adapter.send(
1657+ self.HOME,
1658+ "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.",
1659+ metadata={"notify": True, "job_id": "nightly"},
1660+ )
1661+
1662+ frames = self.connection.messages[frames_before:]
1663+ self.assertEqual([frame["type"] for frame in frames], ["home.deliver"])
1664+ delivery = frames[0]
1665+ self.assertEqual(delivery["protocolVersion"], 3)
1666+ self.assertEqual(delivery["threadId"], self.HOME)
1667+ self.assertEqual(delivery["kind"], "cron")
1668+ self.assertEqual(delivery["label"], "Cron: nightly")
1669+ self.assertTrue(delivery["createdAt"].endswith("Z"))
1670+ self.assertTrue(result.success)
1671+ self.assertEqual(result.message_id, delivery["deliveryId"])
1672+
1673+ # No turn was invented, and none was completed.
1674+ self.assertEqual(self.adapter._active_turns, {})
1675+
1676+ async def test_a_delivery_never_emits_turn_or_item_frames(self):
1677+ frames_before = len(self.connection.messages)
1678+ await self.adapter.send(self.HOME, "♻️ Gateway online — Hermes is back and ready.")
1679+ emitted = {frame["type"] for frame in self.connection.messages[frames_before:]}
1680+ self.assertEqual(emitted, {"home.deliver"})
1681+ self.assertEqual(self.adapter._active_turns, {})
1682+
1683+ async def test_a_notify_stamped_delivery_does_not_complete_the_live_turn(self):
1684+ """THE deadlock regression.
1685+
1686+ A cron delivery landing in Home while the user has a live turn there
1687+ arrives notify-stamped (`_mark_notify_metadata`,
1688+ `gateway/platforms/base.py:89`). Under a naive "no active turn →
1689+ deliver" gate it would fall into the active-turn path, stream as that
1690+ turn's assistant content, and its notify stamp would COMPLETE the
1691+ user's turn with the cron output as the answer. The gate is provenance,
1692+ not turn absence: the cron send does not carry the turn's session key,
1693+ so it becomes a `home.deliver` and the turn keeps running.
1694+ """
1695+ session_id = await self._start_turn(self.HOME, "turn-user")
1696+ # The user's turn has already streamed some of its real answer.
1697+ await self.adapter.send(self.HOME, "Working on it")
1698+ frames_before = len(self.connection.messages)
1699+
1700+ # A cron delivery fires mid-turn, notify-stamped as every final cron
1701+ # delivery is.
1702+ result = await self.adapter.send(
1703+ self.HOME,
1704+ "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.",
1705+ metadata={"notify": True, "job_id": "nightly"},
1706+ )
1707+
1708+ frames = self.connection.messages[frames_before:]
1709+ self.assertEqual([frame["type"] for frame in frames], ["home.deliver"])
1710+ self.assertTrue(result.success)
1711+
1712+ # The user's turn is untouched: still active, still owning its stream.
1713+ self.assertIn(self.HOME, self.adapter._active_turns)
1714+ turn = self.adapter._active_turns[self.HOME]
1715+ self.assertEqual(turn.turn_id, "turn-user")
1716+ self.assertEqual(turn.visible_text, "Working on it")
1717+ self.assertNotIn(
1718+ "turn.completed", [frame["type"] for frame in self.connection.messages]
1719+ )
1720+
1721+ # …and it still completes normally on its own notify, inside its own
1722+ # session context.
1723+ with unittest.mock.patch.object(
1724+ adapter_module.T3PlatformAdapter,
1725+ "_gateway_session_key",
1726+ staticmethod(lambda: session_id),
1727+ ):
1728+ await self.adapter.send(
1729+ self.HOME, "Here is the answer", metadata={"notify": True}
1730+ )
1731+ self.assertNotIn(self.HOME, self.adapter._active_turns)
1732+ self.assertIn(
1733+ "turn.completed", [frame["type"] for frame in self.connection.messages]
1734+ )
1735+
1736+ async def test_a_turn_reply_in_home_is_never_rerouted_to_a_delivery(self):
1737+ """Output produced inside the turn's session context is turn content."""
1738+ session_id = await self._start_turn(self.HOME, "turn-user")
1739+ frames_before = len(self.connection.messages)
1740+
1741+ with unittest.mock.patch.object(
1742+ adapter_module.T3PlatformAdapter,
1743+ "_gateway_session_key",
1744+ staticmethod(lambda: session_id),
1745+ ):
1746+ await self.adapter.send(self.HOME, "Streaming answer")
1747+
1748+ types = [frame["type"] for frame in self.connection.messages[frames_before:]]
1749+ self.assertEqual(types, ["item.started", "content.delta"])
1750+ self.assertNotIn("home.deliver", types)
1751+
1752+ async def test_an_unclassifiable_send_during_a_live_home_turn_stays_with_it(self):
1753+ """The conservative half of the gate.
1754+
1755+ With a live turn in Home and no positive provenance, the send may well
1756+ be that turn's own output arriving from a context where the session key
1757+ did not resolve. Routing it to `home.deliver` would tear a real answer
1758+ out of the turn; leaving it with the turn is at worst a misplacement
1759+ inside the same thread.
1760+ """
1761+ await self._start_turn(self.HOME, "turn-user")
1762+ frames_before = len(self.connection.messages)
1763+
1764+ await self.adapter.send(self.HOME, "Something unclassifiable")
1765+
1766+ types = [frame["type"] for frame in self.connection.messages[frames_before:]]
1767+ self.assertEqual(types, ["item.started", "content.delta"])
1768+ self.assertIn(self.HOME, self.adapter._active_turns)
1769+
1770+ async def test_a_non_home_thread_without_a_turn_still_errors(self):
1771+ """"Message any thread unprompted" stays out of scope."""
1772+ frames_before = len(self.connection.messages)
1773+
1774+ result = await self.adapter.send(
1775+ "some-other-thread",
1776+ "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.",
1777+ metadata={"notify": True, "job_id": "nightly"},
1778+ )
1779+
1780+ self.assertFalse(result.success)
1781+ self.assertEqual(result.error, "no active T3 turn")
1782+ self.assertEqual(self.connection.messages[frames_before:], [])
1783+
1784+ async def test_no_designated_home_means_no_proactive_delivery(self):
1785+ """Before the first `connection.accepted` there is nowhere to deliver."""
1786+ with unittest.mock.patch.dict(
1787+ adapter_module.os.environ, {home_module.HOME_CHANNEL_ENV: ""}
1788+ ):
1789+ result = await self.adapter.send(self.HOME, "Nowhere to go")
1790+ self.assertFalse(result.success)
1791+ self.assertEqual(result.error, "no active T3 turn")
1792+
1793+ async def test_edit_message_has_no_proactive_branch(self):
1794+ """A delivery is an atomic document, not a streaming surface."""
1795+ result = await self.adapter.edit_message(
1796+ self.HOME, "some-message", "Revised delivery", finalize=True
1797+ )
1798+ self.assertFalse(result.success)
1799+ self.assertEqual(result.error, "no active T3 turn")
1800+ self.assertEqual(
1801+ [frame["type"] for frame in self.connection.messages], []
1802+ )
1803+
1804+ async def test_a_delivery_is_queued_before_it_is_sent_and_purged_on_ack(self):
1805+ result = await self.adapter.send(self.HOME, "Queued then acked")
1806+ delivery_id = result.message_id
1807+ self.assertEqual(
1808+ [entry["deliveryId"] for entry in self.queue.entries()], [delivery_id]
1809+ )
1810+
1811+ await self.adapter._handle_server_frame(
1812+ {
1813+ "type": "home.deliver.ack",
1814+ "protocolVersion": 3,
1815+ "deliveryId": delivery_id,
1816+ }
1817+ )
1818+ self.assertEqual(self.queue.entries(), [])
1819+
1820+ async def test_a_delivery_survives_a_dead_socket_and_flushes_on_reconnect(self):
1821+ """Offline delivery: nothing is lost across either side restarting."""
1822+
1823+ class DeadConnection:
1824+ connected = False
1825+
1826+ async def send(self, message):
1827+ raise ConnectionError("T3 Code gateway is offline")
1828+
1829+ self.adapter._connection = DeadConnection()
1830+ with self.assertLogs(adapter_module.logger, level="WARNING"):
1831+ offline = await self.adapter.send(self.HOME, "Sent while offline")
1832+ # Reported successful: it is durably queued and WILL arrive, so a cron
1833+ # job must not log a failure for it.
1834+ self.assertTrue(offline.success)
1835+ self.assertEqual(
1836+ [entry["text"] for entry in self.queue.entries()], ["Sent while offline"]
1837+ )
1838+
1839+ # Reconnect: the accepted frame reconciles the designation and flushes.
1840+ self.adapter._connection = self.connection
1841+ await self.adapter._handle_connection_accepted(
1842+ {
1843+ "type": "connection.accepted",
1844+ "protocolVersion": 3,
1845+ "requestId": "hello-1",
1846+ "instanceId": "instance",
1847+ "nickname": "Hermes",
1848+ "homeThreadId": self.HOME,
1849+ }
1850+ )
1851+
1852+ flushed = self.connection.messages
1853+ self.assertEqual([frame["type"] for frame in flushed], ["home.deliver"])
1854+ self.assertEqual(flushed[0]["text"], "Sent while offline")
1855+ self.assertEqual(flushed[0]["deliveryId"], offline.message_id)
1856+ # Still queued — only the ack purges it.
1857+ self.assertEqual(len(self.queue.entries()), 1)
1858+
1859+ await self.adapter._handle_server_frame(
1860+ {
1861+ "type": "home.deliver.ack",
1862+ "protocolVersion": 3,
1863+ "deliveryId": offline.message_id,
1864+ }
1865+ )
1866+ self.assertEqual(self.queue.entries(), [])
1867+
1868+ async def test_the_queue_flushes_in_fifo_order(self):
1869+ class DeadConnection:
1870+ connected = False
1871+
1872+ async def send(self, message):
1873+ raise ConnectionError("T3 Code gateway is offline")
1874+
1875+ self.adapter._connection = DeadConnection()
1876+ with self.assertLogs(adapter_module.logger, level="WARNING"):
1877+ for text in ("first", "second", "third"):
1878+ await self.adapter.send(self.HOME, text)
1879+
1880+ self.adapter._connection = self.connection
1881+ await self.adapter._flush_home_queue()
1882+
1883+ self.assertEqual(
1884+ [frame["text"] for frame in self.connection.messages],
1885+ ["first", "second", "third"],
1886+ )
1887+
1888+ async def test_connection_accepted_reconciles_the_home_designation(self):
1889+ """T3 owns the designation; a differing local value is overwritten."""
1890+ with unittest.mock.patch.dict(
1891+ adapter_module.os.environ,
1892+ {home_module.HOME_CHANNEL_ENV: "stale-hand-edited-thread"},
1893+ ), unittest.mock.patch.object(
1894+ adapter_module, "save_home_thread_id"
1895+ ) as save:
1896+ await self.adapter._handle_connection_accepted(
1897+ {
1898+ "type": "connection.accepted",
1899+ "protocolVersion": 3,
1900+ "requestId": "hello-1",
1901+ "instanceId": "instance",
1902+ "nickname": "Hermes",
1903+ "homeThreadId": "authoritative-thread",
1904+ }
1905+ )
1906+ save.assert_called_once_with("authoritative-thread")
1907+
1908+ async def test_an_accepted_frame_without_a_home_thread_changes_nothing(self):
1909+ """Resolving the home thread must never fail a handshake."""
1910+ with unittest.mock.patch.object(
1911+ adapter_module, "save_home_thread_id"
1912+ ) as save:
1913+ await self.adapter._handle_connection_accepted(
1914+ {
1915+ "type": "connection.accepted",
1916+ "protocolVersion": 3,
1917+ "requestId": "hello-1",
1918+ "instanceId": "instance",
1919+ "nickname": "Hermes",
1920+ }
1921+ )
1922+ save.assert_not_called()
1923+ self.assertEqual(adapter_module.home_thread_id(), self.HOME)
1924+
1925+ async def test_a_nameless_ack_is_a_correlated_protocol_error(self):
1926+ await self.adapter._handle_server_frame(
1927+ {"type": "home.deliver.ack", "protocolVersion": 3, "requestId": "ack-1"}
1928+ )
1929+ reply = self.connection.messages[-1]
1930+ self.assertEqual(reply["type"], "protocol.error")
1931+ self.assertEqual(reply["code"], "unsupported-message")
1932+
1933+
1934+class EnvEnablementTests(unittest.TestCase):
1935+ """`home_channel` is the magic key that makes `get_home_channel` resolve."""
1936+
1937+ ENROLLED = {
1938+ "HERMES_T3_GATEWAY_URL": "wss://t3.example/api/hermes-gateway/ws",
1939+ "HERMES_T3_GATEWAY_INSTANCE_ID": "instance",
1940+ "HERMES_T3_GATEWAY_CREDENTIAL": "credential",
1941+ }
1942+
1943+ def test_a_designated_home_seeds_the_magic_home_channel_key(self):
1944+ with unittest.mock.patch.dict(
1945+ adapter_module.os.environ,
1946+ {**self.ENROLLED, home_module.HOME_CHANNEL_ENV: "home-thread"},
1947+ ):
1948+ seed = adapter_module.env_enablement()
1949+ # Core pops this key and promotes it to a real HomeChannel dataclass
1950+ # (gateway/config.py:2648-2660), reading only chat_id/name/thread_id.
1951+ # T3 threads are the addressing unit, so chat_id IS the thread id and
1952+ # thread_id stays unset.
1953+ self.assertEqual(
1954+ seed["home_channel"], {"chat_id": "home-thread", "name": "Home"}
1955+ )
1956+
1957+ def test_no_designation_yet_seeds_no_home_channel(self):
1958+ with unittest.mock.patch.dict(
1959+ adapter_module.os.environ,
1960+ {**self.ENROLLED, home_module.HOME_CHANNEL_ENV: ""},
1961+ ):
1962+ seed = adapter_module.env_enablement()
1963+ # The pre-designation window — first connect, before any
1964+ # `connection.accepted`. This is exactly why the `/sethome` nudge
1965+ # suppression is still needed.
1966+ self.assertNotIn("home_channel", seed)
1967+ self.assertEqual(seed["instance_id"], "instance")
1968+
1969+ def test_an_unenrolled_hermes_seeds_nothing_at_all(self):
1970+ with unittest.mock.patch.dict(
1971+ adapter_module.os.environ,
1972+ {**self.ENROLLED, "HERMES_T3_GATEWAY_CREDENTIAL": ""},
1973+ ):
1974+ self.assertIsNone(adapter_module.env_enablement())
1975+
15381976
15391977 if __name__ == "__main__":
15401978 unittest.main()
integrations/hermes-t3-gateway/tests/test_connection.py+9−7
@@ -65,7 +65,7 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 64 unmodified lines
6565 socket = FakeSocket(
6666 {
6767 "type": "connection.accepted",
68− "protocolVersion": 2,
68+ "protocolVersion": 3,
6969 "instanceId": "provider-instance",
7070 "nickname": "Research",
7171 "credential": "persistent-secret",
@@ -86,7 +86,9 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 14 unmodified lines
8686 socket = FakeSocket(
8787 {
8888 "type": "connection.accepted",
89− "protocolVersion": 1,
89+ # A v2 server: the version policy stays fail-closed across the
90+ # v3 bump, so this must not be silently accepted.
91+ "protocolVersion": 2,
9092 "instanceId": "provider-instance",
9193 "nickname": "Research",
9294 }
@@ -108,7 +110,7 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 15 unmodified lines
108110 "type": "connection.rejected",
109111 "code": "version-incompatible",
110112 "message": "upgrade required",
111− "expectedProtocolVersion": 2,
113+ "expectedProtocolVersion": 3,
112114 }
113115 )
114116 with self.assertRaises(connection.ConnectionRejected) as raised:
@@ -148,14 +150,14 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 33 unmodified lines
148150 json.dumps(
149151 {
150152 "type": "ping",
151− "protocolVersion": 2,
153+ "protocolVersion": 3,
152154 "requestId": "server-ping-1",
153155 }
154156 ),
155157 json.dumps(
156158 {
157159 "type": "connection.accepted",
158− "protocolVersion": 2,
160+ "protocolVersion": 3,
159161 "requestId": hello_id,
160162 "instanceId": "provider-instance",
161163 "nickname": "Hermes",
@@ -206,7 +208,7 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 44 unmodified lines
206208 return json.dumps(
207209 {
208210 "type": "connection.accepted",
209− "protocolVersion": 2,
211+ "protocolVersion": 3,
210212 "requestId": self.sent[0]["requestId"],
211213 "instanceId": "provider-instance",
212214 "nickname": "Hermes",
@@ -220,7 +222,7 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 7 unmodified lines
220222 async def frames():
221223 yield json.dumps({"type": "turn.start", "requestId": "turn-1"})
222224 yield json.dumps(
223− {"type": "ping", "protocolVersion": 2, "requestId": "ping-1"}
225+ {"type": "ping", "protocolVersion": 3, "requestId": "ping-1"}
224226 )
225227 await released.wait()
226228
integrations/hermes-t3-gateway/tests/test_home.py+473−0
@@ -0,0 +1,473 @@
1+from __future__ import annotations
2+
3+import importlib.util
4+import json
5+import pathlib
6+import sys
7+import tempfile
8+import types
9+import unittest
10+import unittest.mock
11+
12+ROOT = pathlib.Path(__file__).resolve().parents[1]
13+PACKAGE = "hermes_t3_gateway_home_test"
14+
15+# `home.py` depends only on `protocol.py` at import time (`connection.py` is
16+# imported lazily inside the standalone sender), so this loader needs none of
17+# the fake `gateway.*` modules the adapter tests install.
18+package = types.ModuleType(PACKAGE)
19+package.__path__ = [str(ROOT)]
20+sys.modules.setdefault(PACKAGE, package)
21+
22+for _name in ("protocol", "connection", "home"):
23+ _spec = importlib.util.spec_from_file_location(
24+ f"{PACKAGE}.{_name}", ROOT / f"{_name}.py"
25+ )
26+ assert _spec and _spec.loader
27+ _module = importlib.util.module_from_spec(_spec)
28+ sys.modules[f"{PACKAGE}.{_name}"] = _module
29+ _spec.loader.exec_module(_module)
30+
31+home = sys.modules[f"{PACKAGE}.home"]
32+protocol = sys.modules[f"{PACKAGE}.protocol"]
33+connection = sys.modules[f"{PACKAGE}.connection"]
34+
35+
36+def make_delivery(text: str, thread_id: str = "home-thread", **kwargs):
37+ return home.build_delivery(thread_id=thread_id, text=text, **kwargs)
38+
39+
40+class QueueTests(unittest.TestCase):
41+ def setUp(self):
42+ self._tmp = tempfile.TemporaryDirectory()
43+ self.addCleanup(self._tmp.cleanup)
44+ self.path = pathlib.Path(self._tmp.name) / "gateway" / "queue.jsonl"
45+ self.queue = home.HomeDeliveryQueue(path=self.path)
46+
47+ def test_queue_replays_in_fifo_order(self):
48+ """Deliveries flush oldest-first, so a Home transcript reads in order."""
49+ first = make_delivery("first")
50+ second = make_delivery("second")
51+ third = make_delivery("third")
52+ for entry in (first, second, third):
53+ self.assertTrue(self.queue.append(entry))
54+
55+ self.assertEqual(
56+ [entry["text"] for entry in self.queue.entries()],
57+ ["first", "second", "third"],
58+ )
59+
60+ def test_an_entry_is_purged_only_by_its_own_ack(self):
61+ """The durability guarantee: nothing leaves the queue without an ack.
62+
63+ T3 acks only after a durable write, so purging on anything else — a
64+ successful `send()`, a reconnect, a flush — would drop deliveries the
65+ server never actually stored.
66+ """
67+ first = make_delivery("first")
68+ second = make_delivery("second")
69+ self.queue.append(first)
70+ self.queue.append(second)
71+
72+ # Sending, flushing, or re-reading changes nothing.
73+ self.assertEqual(len(self.queue.entries()), 2)
74+ self.assertEqual(len(self.queue.entries()), 2)
75+
76+ # An unknown id purges nothing at all.
77+ self.assertFalse(self.queue.purge("never-sent"))
78+ self.assertEqual(len(self.queue.entries()), 2)
79+
80+ self.assertTrue(self.queue.purge(first["deliveryId"]))
81+ self.assertEqual(
82+ [entry["deliveryId"] for entry in self.queue.entries()],
83+ [second["deliveryId"]],
84+ )
85+
86+ # A duplicate ack (a replayed flush T3 deduped) is inert.
87+ self.assertFalse(self.queue.purge(first["deliveryId"]))
88+ self.assertEqual(len(self.queue.entries()), 1)
89+
90+ def test_the_queue_is_capped_and_drops_the_oldest(self):
91+ """A wedged queue must not grow without bound — and must not go stale.
92+
93+ Dropping newest would make an over-full queue permanently swallow
94+ current output; dropping oldest loses the least valuable entries and
95+ keeps today's cron brief.
96+ """
97+ queue = home.HomeDeliveryQueue(path=self.path, max_entries=3)
98+ deliveries = [make_delivery(f"delivery-{index}") for index in range(5)]
99+ with self.assertLogs(home.logger, level="WARNING") as captured:
100+ for entry in deliveries:
101+ queue.append(entry)
102+
103+ self.assertTrue(
104+ any("queue is full" in line for line in captured.output),
105+ captured.output,
106+ )
107+ self.assertEqual(
108+ [entry["text"] for entry in queue.entries()],
109+ ["delivery-2", "delivery-3", "delivery-4"],
110+ )
111+
112+ def test_appending_the_same_delivery_twice_is_idempotent(self):
113+ """A retried standalone send must not double-queue its own payload."""
114+ entry = make_delivery("once")
115+ self.assertTrue(self.queue.append(entry))
116+ self.assertTrue(self.queue.append(dict(entry)))
117+ self.assertEqual(len(self.queue.entries()), 1)
118+
119+ def test_a_torn_line_does_not_discard_the_whole_queue(self):
120+ """A process killed mid-write must cost one entry, not the outbox."""
121+ good = make_delivery("survivor")
122+ self.queue.append(good)
123+ with self.path.open("a", encoding="utf-8") as handle:
124+ handle.write('{"deliveryId": "truncated"\n')
125+
126+ self.assertEqual(
127+ [entry["deliveryId"] for entry in self.queue.entries()],
128+ [good["deliveryId"]],
129+ )
130+
131+ def test_the_queue_survives_a_process_restart(self):
132+ """Durability across a plugin restart is the entire point of the file."""
133+ entry = make_delivery("across-restart")
134+ self.queue.append(entry)
135+
136+ reopened = home.HomeDeliveryQueue(path=self.path)
137+ self.assertEqual(
138+ [item["deliveryId"] for item in reopened.entries()],
139+ [entry["deliveryId"]],
140+ )
141+
142+ def test_the_queue_lives_under_the_active_hermes_home(self):
143+ """Profile-scoped: a second profile must not replay the first's output."""
144+ with unittest.mock.patch.object(
145+ home, "hermes_home", return_value=pathlib.Path("/tmp/profile-b")
146+ ):
147+ self.assertEqual(
148+ home.queue_path(),
149+ pathlib.Path("/tmp/profile-b/gateway/t3_home_delivery_queue.jsonl"),
150+ )
151+
152+
153+class ClassificationTests(unittest.TestCase):
154+ def test_the_cron_job_id_metadata_is_the_strongest_signal(self):
155+ kind, label, certain = home.classify_delivery(
156+ "Anything at all", {"job_id": "daily-digest", "notify": True}
157+ )
158+ self.assertEqual(kind, "cron")
159+ self.assertEqual(label, "Cron: daily-digest")
160+ self.assertTrue(certain)
161+
162+ def test_the_cron_wrap_header_supplies_a_human_job_name(self):
163+ content = (
164+ "Cronjob Response: Morning digest\n"
165+ "(job_id: abc123)\n"
166+ "-------------\n\n"
167+ "Three things happened.\n"
168+ )
169+ kind, label, certain = home.classify_delivery(content, None)
170+ self.assertEqual(kind, "cron")
171+ self.assertEqual(label, "Cron: Morning digest")
172+ self.assertTrue(certain)
173+
174+ def test_gateway_lifecycle_notices_land_quietly(self):
175+ for content in (
176+ "♻️ Gateway online — Hermes is back and ready.",
177+ "♻ Gateway restarted successfully. Your session continues.",
178+ "⚠️ Gateway shutting down — Your current task will be interrupted.",
179+ "⚠️ Gateway restarting — Your current task will be interrupted. "
180+ "Send any message after restart and I'll try to resume where you "
181+ "left off.",
182+ ):
183+ with self.subTest(content=content[:32]):
184+ kind, label, certain = home.classify_delivery(content, None)
185+ self.assertEqual(kind, "lifecycle")
186+ self.assertEqual(label, "Gateway")
187+ self.assertTrue(certain)
188+
189+ def test_a_handoff_is_recognised_from_its_synthetic_session_identity(self):
190+ kind, label, certain = home.classify_delivery(
191+ "Picking up where the CLI left off.",
192+ None,
193+ session_user_id="system:handoff",
194+ )
195+ self.assertEqual(kind, "handoff")
196+ self.assertEqual(label, "Handoff")
197+ self.assertTrue(certain)
198+
199+ def test_an_unrecognised_send_defaults_to_an_uncertain_message(self):
200+ """Worst case is a wrong badge — and, in the live-turn window, a send
201+ that stays with the turn rather than being torn out of it."""
202+ kind, label, certain = home.classify_delivery("Just checking in.", None)
203+ self.assertEqual(kind, "message")
204+ self.assertEqual(label, "Hermes")
205+ self.assertFalse(certain)
206+
207+
208+class FrameTests(unittest.TestCase):
209+ def test_home_deliver_applies_every_wire_bound(self):
210+ frame = protocol.home_deliver(
211+ delivery_id_value="delivery-1",
212+ thread_id="home-thread",
213+ kind="cron",
214+ label=" " + "L" * 400 + " ",
215+ text="x" * (protocol.MAX_HOME_DELIVERY_TEXT_CHARS + 500),
216+ created_at="2026-07-26T00:00:00Z",
217+ )
218+ self.assertEqual(frame["type"], "home.deliver")
219+ self.assertEqual(frame["protocolVersion"], 3)
220+ self.assertEqual(frame["deliveryId"], "delivery-1")
221+ self.assertEqual(frame["threadId"], "home-thread")
222+ self.assertEqual(frame["kind"], "cron")
223+ self.assertEqual(len(frame["label"]), protocol.MAX_HOME_DELIVERY_LABEL_CHARS)
224+ self.assertEqual(
225+ len(frame["text"]), protocol.MAX_HOME_DELIVERY_TEXT_CHARS
226+ )
227+ self.assertEqual(frame["createdAt"], "2026-07-26T00:00:00Z")
228+
229+ def test_home_deliver_never_emits_an_invalid_kind_or_empty_label(self):
230+ frame = protocol.home_deliver(
231+ delivery_id_value="delivery-2",
232+ thread_id="home-thread",
233+ kind="not-a-kind",
234+ label=" ",
235+ text="",
236+ )
237+ # A misclassification must cost a badge, never a server rejection of a
238+ # delivery the plugin has already queued.
239+ self.assertEqual(frame["kind"], "other")
240+ self.assertEqual(frame["label"], "Hermes")
241+ self.assertTrue(len(frame["text"]) >= 1)
242+
243+ def test_home_deliver_ack_is_an_accepted_server_command(self):
244+ message = {"type": "home.deliver.ack", "protocolVersion": 3}
245+ self.assertEqual(protocol.validate_server_frame(message), message)
246+
247+ def test_hello_declares_its_connection_role(self):
248+ gateway = protocol.connection_hello(
249+ hermes_version="0.19.0",
250+ authentication={"type": "instance-credential"},
251+ )
252+ self.assertEqual(gateway["role"], "gateway")
253+ delivery = protocol.connection_hello(
254+ hermes_version="0.19.0",
255+ authentication={"type": "instance-credential"},
256+ role="delivery",
257+ )
258+ self.assertEqual(delivery["role"], "delivery")
259+ # An unknown role must never silently become "delivery" — the safe
260+ # default is the ordinary connection.
261+ unknown = protocol.connection_hello(
262+ hermes_version="0.19.0",
263+ authentication={"type": "instance-credential"},
264+ role="nonsense",
265+ )
266+ self.assertEqual(unknown["role"], "gateway")
267+
268+
269+class MockDeliveryServer:
270+ """A T3 stand-in for the standalone sender's short-lived socket."""
271+
272+ def __init__(self, *, ack: bool = True):
273+ self.ack = ack
274+ self.sent: list[dict] = []
275+ self.closed = False
276+ self._outbox: list[str] = []
277+
278+ async def send(self, raw):
279+ message = json.loads(raw)
280+ self.sent.append(message)
281+ if message.get("type") == "connection.hello":
282+ self._outbox.append(
283+ json.dumps(
284+ {
285+ "type": "connection.accepted",
286+ "protocolVersion": protocol.PROTOCOL_VERSION,
287+ "requestId": message["requestId"],
288+ "instanceId": "provider-instance",
289+ "nickname": "Hermes",
290+ "homeThreadId": "home-thread",
291+ }
292+ )
293+ )
294+ elif message.get("type") == "home.deliver" and self.ack:
295+ self._outbox.append(
296+ json.dumps(
297+ {
298+ "type": "home.deliver.ack",
299+ "protocolVersion": protocol.PROTOCOL_VERSION,
300+ "deliveryId": message["deliveryId"],
301+ }
302+ )
303+ )
304+
305+ async def recv(self):
306+ if not self._outbox:
307+ raise AssertionError("the mock server was polled with nothing to send")
308+ return self._outbox.pop(0)
309+
310+ async def close(self):
311+ self.closed = True
312+
313+
314+class StandaloneSenderTests(unittest.IsolatedAsyncioTestCase):
315+ def setUp(self):
316+ self._tmp = tempfile.TemporaryDirectory()
317+ self.addCleanup(self._tmp.cleanup)
318+ self.queue_file = pathlib.Path(self._tmp.name) / "gateway" / "queue.jsonl"
319+ patch = unittest.mock.patch.object(
320+ home, "hermes_home", return_value=pathlib.Path(self._tmp.name)
321+ )
322+ patch.start()
323+ self.addCleanup(patch.stop)
324+ self.environment = unittest.mock.patch.dict(
325+ home.os.environ,
326+ {
327+ "HERMES_T3_GATEWAY_URL": "wss://t3.example/api/hermes-gateway/ws",
328+ "HERMES_T3_GATEWAY_INSTANCE_ID": "provider-instance",
329+ "HERMES_T3_GATEWAY_CREDENTIAL": "secret",
330+ home.HOME_CHANNEL_ENV: "home-thread",
331+ },
332+ )
333+ self.environment.start()
334+ self.addCleanup(self.environment.stop)
335+
336+ def _serve(self, server):
337+ async def _open(_url):
338+ return server
339+
340+ return unittest.mock.patch.object(connection, "_open_socket", _open)
341+
342+ async def test_standalone_send_hellos_as_delivery_then_acks_and_closes(self):
343+ """The full out-of-process cron path.
344+
345+ `role: "delivery"` is load-bearing: T3's broker registers a `gateway`
346+ connection under generation fencing and displaces its predecessor, so a
347+ cron dial-in announcing the default role would kick the live gateway
348+ socket off its own instance mid-turn.
349+ """
350+ server = MockDeliveryServer()
351+ with self._serve(server):
352+ result = await home.standalone_send(
353+ None,
354+ "home-thread",
355+ "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.",
356+ )
357+
358+ self.assertTrue(result["success"])
359+ self.assertEqual(
360+ [message["type"] for message in server.sent],
361+ ["connection.hello", "home.deliver"],
362+ )
363+ hello, delivery = server.sent
364+ self.assertEqual(hello["role"], "delivery")
365+ self.assertEqual(hello["protocolVersion"], 3)
366+ self.assertEqual(
367+ hello["authentication"],
368+ {
369+ "type": "instance-credential",
370+ "instanceId": "provider-instance",
371+ "credential": "secret",
372+ },
373+ )
374+ self.assertEqual(delivery["threadId"], "home-thread")
375+ self.assertEqual(delivery["kind"], "cron")
376+ self.assertEqual(delivery["label"], "Cron: nightly")
377+ self.assertEqual(result["message_id"], delivery["deliveryId"])
378+ self.assertTrue(server.closed, "the delivery socket must not linger")
379+
380+ # Acked, so nothing is left queued for the live gateway to replay.
381+ self.assertEqual(home.HomeDeliveryQueue().entries(), [])
382+
383+ async def test_an_unreachable_t3_queues_rather_than_failing_the_cron_job(self):
384+ """A cron job must not report failure for output that will arrive."""
385+
386+ async def _refuse(_url):
387+ raise ConnectionRefusedError("T3 is down")
388+
389+ with unittest.mock.patch.object(connection, "_open_socket", _refuse):
390+ result = await home.standalone_send(None, "home-thread", "Nightly brief")
391+
392+ self.assertTrue(result["success"])
393+ self.assertTrue(result["queued"])
394+ queued = home.HomeDeliveryQueue().entries()
395+ self.assertEqual([entry["text"] for entry in queued], ["Nightly brief"])
396+ self.assertEqual(queued[0]["deliveryId"], result["message_id"])
397+
398+ async def test_an_unacknowledged_delivery_stays_queued(self):
399+ """No ack, no purge — the live gateway retries it on the next connect."""
400+ server = MockDeliveryServer(ack=False)
401+ with self._serve(server), unittest.mock.patch.object(
402+ home, "_deliver_over_short_lived_socket", return_value=False
403+ ):
404+ result = await home.standalone_send(None, "home-thread", "Unacked brief")
405+
406+ self.assertTrue(result["success"])
407+ self.assertTrue(result["queued"])
408+ self.assertEqual(
409+ [entry["text"] for entry in home.HomeDeliveryQueue().entries()],
410+ ["Unacked brief"],
411+ )
412+
413+ async def test_standalone_send_refuses_when_hermes_is_not_enrolled(self):
414+ with unittest.mock.patch.dict(
415+ home.os.environ, {"HERMES_T3_GATEWAY_CREDENTIAL": ""}
416+ ):
417+ result = await home.standalone_send(None, "home-thread", "Brief")
418+ self.assertIn("not enrolled", result["error"])
419+
420+ async def test_standalone_send_falls_back_to_the_designated_home_thread(self):
421+ server = MockDeliveryServer()
422+ with self._serve(server):
423+ result = await home.standalone_send(None, "", "Brief with no chat id")
424+ self.assertTrue(result["success"])
425+ self.assertEqual(server.sent[1]["threadId"], "home-thread")
426+
427+
428+class DesignationTests(unittest.TestCase):
429+ def test_saving_the_designation_mirrors_it_into_this_process(self):
430+ """The running gateway must not need a restart to see a new home."""
431+ saved = {}
432+
433+ config = types.ModuleType("hermes_cli.config")
434+ config.save_env_value = lambda key, value: saved.__setitem__(key, value)
435+ package = types.ModuleType("hermes_cli")
436+ package.__path__ = []
437+ with unittest.mock.patch.dict(
438+ sys.modules, {"hermes_cli": package, "hermes_cli.config": config}
439+ ), unittest.mock.patch.dict(home.os.environ, {}, clear=False):
440+ self.assertTrue(home.save_home_thread_id("thread-home"))
441+ self.assertEqual(saved, {home.HOME_CHANNEL_ENV: "thread-home"})
442+ self.assertEqual(home.home_thread_id(), "thread-home")
443+
444+ def test_an_unwritable_env_still_routes_for_this_process(self):
445+ """A managed or read-only `.env` degrades; it must not break routing."""
446+
447+ def refuse(key, value):
448+ raise PermissionError("managed .env")
449+
450+ config = types.ModuleType("hermes_cli.config")
451+ config.save_env_value = refuse
452+ package = types.ModuleType("hermes_cli")
453+ package.__path__ = []
454+ with unittest.mock.patch.dict(
455+ sys.modules, {"hermes_cli": package, "hermes_cli.config": config}
456+ ), unittest.mock.patch.dict(
457+ home.os.environ, {}, clear=False
458+ ), self.assertLogs(home.logger, level="WARNING"):
459+ self.assertFalse(home.save_home_thread_id("thread-home"))
460+ # Not durable, but routable: this gateway delivers to the right
461+ # thread until it restarts, and re-reconciles on the next connect.
462+ self.assertEqual(home.home_thread_id(), "thread-home")
463+
464+ def test_an_empty_designation_is_ignored(self):
465+ with unittest.mock.patch.dict(
466+ home.os.environ, {home.HOME_CHANNEL_ENV: "keep-me"}
467+ ):
468+ self.assertFalse(home.save_home_thread_id(" "))
469+ self.assertEqual(home.home_thread_id(), "keep-me")
470+
471+
472+if __name__ == "__main__":
473+ unittest.main()
integrations/hermes-t3-gateway/tests/test_protocol.py+8−8
@@ -66,7 +66,7 @@ def fake_hermes_skills(skills_list=None, skill_view=None): · 65 unmodified lines
6666
6767
6868 class ProtocolTests(unittest.TestCase):
69− def test_hello_matches_v2_contract(self):
69+ def test_hello_matches_v3_contract(self):
7070 hello = protocol.connection_hello(
7171 hermes_version="0.19.0",
7272 authentication={"type": "enrollment-token", "token": "once"},
@@ -75,7 +75,7 @@ class ProtocolTests(unittest.TestCase): · 2 unmodified lines
7575 )
7676 self.assertEqual(hello["type"], "connection.hello")
7777 self.assertEqual(hello["requestId"], "request-1")
78− self.assertEqual(hello["protocolVersion"], 2)
78+ self.assertEqual(hello["protocolVersion"], 3)
7979 self.assertFalse(hello["capabilities"]["attachments"])
8080 self.assertTrue(hello["capabilities"]["streaming"])
8181 self.assertEqual(hello["model"], "gpt-5.6-terra")
@@ -125,15 +125,15 @@ class ProtocolTests(unittest.TestCase): · 43 unmodified lines
125125
126126 def test_server_frame_validation_is_closed(self):
127127 with self.assertRaisesRegex(ValueError, "unsupported"):
128− protocol.validate_server_frame({"type": "made.up", "protocolVersion": 2})
128+ protocol.validate_server_frame({"type": "made.up", "protocolVersion": 3})
129129 with self.assertRaisesRegex(ValueError, "version"):
130− # Protocol v1 peers must upgrade before sending runtime frames.
131− protocol.validate_server_frame({"type": "ping", "protocolVersion": 1})
130+ # Protocol v2 peers must upgrade before sending runtime frames.
131+ protocol.validate_server_frame({"type": "ping", "protocolVersion": 2})
132132
133133 def test_describe_frames_are_accepted_server_commands(self):
134134 for frame_type in ("describe.request", "skill.body.request"):
135135 with self.subTest(frame_type=frame_type):
136− message = {"type": frame_type, "protocolVersion": 2}
136+ message = {"type": frame_type, "protocolVersion": 3}
137137 self.assertEqual(protocol.validate_server_frame(message), message)
138138
139139 # ── describe.response ──────────────────────────────────────────────
@@ -151,7 +151,7 @@ class ProtocolTests(unittest.TestCase): · 11 unmodified lines
151151 )
152152 self.assertEqual(response["type"], "describe.response")
153153 self.assertEqual(response["requestId"], "describe-1")
154− self.assertEqual(response["protocolVersion"], 2)
154+ self.assertEqual(response["protocolVersion"], 3)
155155 self.assertEqual(response["pluginVersion"], protocol.PLUGIN_VERSION)
156156 self.assertEqual(response["hermesVersion"], "0.19.0")
157157 self.assertEqual(response["model"], "gpt-5.6-terra")
@@ -282,7 +282,7 @@ class ProtocolTests(unittest.TestCase): · 124 unmodified lines
282282 )
283283 self.assertEqual(response["type"], "skill.body.response")
284284 self.assertEqual(response["requestId"], "body-1")
285− self.assertEqual(response["protocolVersion"], 2)
285+ self.assertEqual(response["protocolVersion"], 3)
286286 self.assertEqual(response["skillName"], "codex")
287287 self.assertEqual(response["markdown"], "# Codex\n\nDelegate coding.")
288288
packages/client-runtime/src/state/threadReducer.test.ts+82−0
@@ -444,6 +444,88 @@ describe("applyThreadDetailEvent", () => { · 443 unmodified lines
444444 });
445445 });
446446
447+ describe("thread.notification-delivered", () => {
448+ const deliveryEvent = {
449+ ...baseEventFields,
450+ sequence: 20,
451+ occurredAt: "2026-04-01T07:00:00.000Z",
452+ aggregateKind: "thread",
453+ aggregateId: ThreadId.make("thread-1"),
454+ type: "thread.notification-delivered",
455+ payload: {
456+ threadId: ThreadId.make("thread-1"),
457+ messageId: MessageId.make("msg-notify"),
458+ text: "Digest ready.",
459+ notification: {
460+ kind: "cron",
461+ label: "Cron: daily-digest",
462+ deliveryId: "delivery-1",
463+ },
464+ createdAt: "2026-04-01T07:00:00.000Z",
465+ updatedAt: "2026-04-01T07:00:00.000Z",
466+ },
467+ } as const;
468+
469+ it("appends the delivery as a turnless assistant message carrying provenance", () => {
470+ const result = applyThreadDetailEvent(baseThread, deliveryEvent);
471+
472+ expect(result.kind).toBe("updated");
473+ if (result.kind === "updated") {
474+ expect(result.thread.messages).toHaveLength(1);
475+ expect(result.thread.messages[0]).toEqual({
476+ id: "msg-notify",
477+ role: "assistant",
478+ text: "Digest ready.",
479+ turnId: null,
480+ streaming: false,
481+ notification: {
482+ kind: "cron",
483+ label: "Cron: daily-digest",
484+ deliveryId: "delivery-1",
485+ },
486+ createdAt: "2026-04-01T07:00:00.000Z",
487+ updatedAt: "2026-04-01T07:00:00.000Z",
488+ });
489+ expect(result.thread.updatedAt).toBe("2026-04-01T07:00:00.000Z");
490+ }
491+ });
492+
493+ it("leaves an unrelated live turn untouched", () => {
494+ // A delivery can land mid-turn; attributing it to that turn would fold
495+ // it away behind the turn's summary and mis-settle the turn state.
496+ const threadWithRunningTurn: OrchestrationThread = {
497+ ...baseThread,
498+ latestTurn: {
499+ turnId: TurnId.make("turn-1"),
500+ state: "running",
501+ requestedAt: "2026-04-01T06:00:00.000Z",
502+ startedAt: "2026-04-01T06:00:00.000Z",
503+ completedAt: null,
504+ assistantMessageId: null,
505+ },
506+ };
507+
508+ const result = applyThreadDetailEvent(threadWithRunningTurn, deliveryEvent);
509+ expect(result.kind).toBe("updated");
510+ if (result.kind === "updated") {
511+ expect(result.thread.latestTurn).toEqual(threadWithRunningTurn.latestTurn);
512+ }
513+ });
514+
515+ it("replaces rather than duplicates on a replayed delivery", () => {
516+ const first = applyThreadDetailEvent(baseThread, deliveryEvent);
517+ expect(first.kind).toBe("updated");
518+ if (first.kind !== "updated") return;
519+
520+ const second = applyThreadDetailEvent(first.thread, { ...deliveryEvent, sequence: 21 });
521+ expect(second.kind).toBe("updated");
522+ if (second.kind === "updated") {
523+ expect(second.thread.messages).toHaveLength(1);
524+ expect(second.thread.messages[0]).toEqual(first.thread.messages[0]);
525+ }
526+ });
527+ });
528+
447529 describe("thread.session-set", () => {
448530 it("settles a running latestTurn when the session leaves the running status", () => {
449531 const threadWithRunningTurn: OrchestrationThread = {
packages/client-runtime/src/state/threadReducer.ts+30−0
@@ -325,6 +325,36 @@ export function applyThreadDetailEvent( · 324 unmodified lines
325325 };
326326 }
327327
328+ case "thread.notification-delivered": {
329+ // A proactive delivery is an ordinary transcript message carrying
330+ // provenance. It binds to no turn, so latestTurn and the checkpoint
331+ // rebinding that `thread.message-sent` performs are deliberately
332+ // untouched — attributing it to the live turn would fold it away
333+ // behind that turn's summary.
334+ const message: OrchestrationMessage = {
335+ id: event.payload.messageId,
336+ role: "assistant",
337+ text: event.payload.text,
338+ turnId: null,
339+ streaming: false,
340+ notification: event.payload.notification,
341+ createdAt: event.payload.createdAt,
342+ updatedAt: event.payload.updatedAt,
343+ };
344+
345+ // Replays re-emit the persisted row verbatim, so replacing in place
346+ // keeps the transcript stable instead of duplicating the delivery.
347+ const existingMessage = thread.messages.find((entry) => entry.id === message.id);
348+ const messages = existingMessage
349+ ? Arr.map(thread.messages, (entry) => (entry.id === message.id ? message : entry))
350+ : Arr.append(thread.messages, message);
351+
352+ return {
353+ kind: "updated",
354+ thread: { ...thread, messages, updatedAt: event.occurredAt },
355+ };
356+ }
357+
328358 // ── Session ─────────────────────────────────────────────────────
329359 case "thread.session-set": {
330360 // Leaving the "running" session status is the turn-end signal: settle a
packages/client-runtime/src/state/threadSort.test.ts+40−0
@@ -68,4 +68,44 @@ describe("sortThreads", () => { · 67 unmodified lines
6868
6969 expect(sorted.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]);
7070 });
71+
72+ it("pins the requested thread first regardless of its timestamp", () => {
73+ // The agent home thread: a designated destination, not a conversation the
74+ // user started, so staleness must not demote it.
75+ const sorted = sortThreads(
76+ [
77+ makeThread({ id: "thread-newest", updatedAt: "2026-03-09T12:00:00.000Z" }),
78+ makeThread({ id: "thread-home", updatedAt: "2026-01-01T00:00:00.000Z" }),
79+ makeThread({ id: "thread-middle", updatedAt: "2026-03-09T10:00:00.000Z" }),
80+ ],
81+ "updated_at",
82+ "thread-home",
83+ );
84+
85+ expect(sorted.map((thread) => thread.id)).toEqual([
86+ "thread-home",
87+ "thread-newest",
88+ "thread-middle",
89+ ]);
90+ });
91+
92+ it("sorts unchanged when no thread is pinned or the pinned id is unknown", () => {
93+ const threads = [
94+ makeThread({ id: "thread-newest", updatedAt: "2026-03-09T12:00:00.000Z" }),
95+ makeThread({ id: "thread-oldest", updatedAt: "2026-01-01T00:00:00.000Z" }),
96+ ];
97+
98+ expect(sortThreads(threads, "updated_at").map((thread) => thread.id)).toEqual([
99+ "thread-newest",
100+ "thread-oldest",
101+ ]);
102+ expect(sortThreads(threads, "updated_at", null).map((thread) => thread.id)).toEqual([
103+ "thread-newest",
104+ "thread-oldest",
105+ ]);
106+ expect(sortThreads(threads, "updated_at", "thread-gone").map((thread) => thread.id)).toEqual([
107+ "thread-newest",
108+ "thread-oldest",
109+ ]);
110+ });
71111 });
packages/client-runtime/src/state/threadSort.ts+17−0
@@ -69,18 +69,35 @@ export function getThreadSortTimestamp( · 68 unmodified lines
6969 return getLatestUserMessageTimestamp(thread);
7070 }
7171
72+/**
73+ * Sort threads for a list, optionally pinning one thread to the top.
74+ *
75+ * `pinnedThreadId` exists for the agent home thread: it is a designated
76+ * destination rather than a conversation the user started, so it must hold
77+ * position one no matter how stale it looks — an agent whose last cron ran a
78+ * month ago still has to be the first row of its instance. The pin is a
79+ * leading sort key rather than a splice so a single pass produces the whole
80+ * ordering, and every downstream consumer of that ordering (preview slice,
81+ * keyboard traversal, prewarming) inherits it for free.
82+ *
83+ * A pinned id that matches no thread changes nothing.
84+ */
7285 export function sortThreads<T extends { readonly id: string } & ThreadSortInput>(
7386 threads: readonly T[],
7487 sortOrder: SidebarThreadSortOrder,
88+ pinnedThreadId?: string | null,
7589 ): T[] {
7690 return Arr.sort(
7791 threads,
7892 Order.mapInput(
7993 Order.Struct({
94+ // `Order.Boolean` puts `false` first; flipped, the pinned row leads.
95+ pinned: Order.flip(Order.Boolean),
8096 timestamp: Order.flip(Order.Number),
8197 id: Order.flip(Order.String),
8298 }),
8399 (thread: T) => ({
100+ pinned: pinnedThreadId != null && thread.id === pinnedThreadId,
84101 timestamp: getThreadSortTimestamp(thread, sortOrder),
85102 id: thread.id,
86103 }),
packages/contracts/src/hermesGateway.test.ts+113−20
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 PROVIDER_DISPLAY_NAMES,
99 } from "./model.ts";
1010 import {
11+ HERMES_GATEWAY_PROTOCOL_VERSION,
1112 HermesGatewayCapabilities,
1213 HermesGatewayConnectionHello,
1314 HermesGatewayCreateEnrollmentInput,
@@ -72,9 +73,9 @@ describe("Hermes gateway management contracts", () => { · 58 unmodified lines
7273 model: "gpt-5.6-terra",
7374 connectionGeneration: 3,
7475 activeSessionCount: 2,
75− protocolVersion: 2,
76+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
7677 capabilities: {
77− protocolVersion: 2,
78+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
7879 streaming: true,
7980 activity: true,
8081 approvals: true,
@@ -107,11 +108,11 @@ describe("Hermes gateway handshake", () => { · 26 unmodified lines
107108 const hello = decodeHello({
108109 type: "connection.hello",
109110 requestId: "hello-1",
110− protocolVersion: 2,
111+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
111112 pluginVersion: "0.2.0",
112113 hermesVersion: "1.2.3",
113114 capabilities: {
114− protocolVersion: 2,
115+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
115116 streaming: true,
116117 activity: true,
117118 approvals: true,
@@ -131,11 +132,11 @@ describe("Hermes gateway handshake", () => { · 13 unmodified lines
131132 const hello = decodeHello({
132133 type: "connection.hello",
133134 requestId: "hello-2",
134− protocolVersion: 2,
135+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
135136 pluginVersion: "0.2.0",
136137 hermesVersion: "1.2.3",
137138 capabilities: {
138− protocolVersion: 2,
139+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
139140 streaming: true,
140141 activity: true,
141142 approvals: true,
@@ -161,11 +162,11 @@ describe("Hermes gateway handshake", () => { · 19 unmodified lines
161162 const hello = decodeHello({
162163 type: "connection.hello",
163164 requestId: "hello-no-model",
164− protocolVersion: 2,
165+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
165166 pluginVersion: "0.2.0",
166167 hermesVersion: "1.2.3",
167168 capabilities: {
168− protocolVersion: 2,
169+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
169170 streaming: true,
170171 activity: true,
171172 approvals: true,
@@ -210,7 +211,7 @@ describe("Hermes gateway handshake", () => { · 38 unmodified lines
210211 it("reserves attachments for a future protocol version", () => {
211212 expect(() =>
212213 decodeCapabilities({
213− protocolVersion: 2,
214+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
214215 streaming: true,
215216 activity: true,
216217 approvals: true,
@@ -221,12 +222,12 @@ describe("Hermes gateway handshake", () => { · 4 unmodified lines
221222 });
222223 });
223224
224−describe("T3 to Hermes v2 messages", () => {
225+describe("T3 to Hermes messages", () => {
225226 it("decodes session creation and opaque resume cursors", () => {
226227 expect(
227228 decodeT3Message({
228229 type: "session.ensure",
229− protocolVersion: 2,
230+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
230231 requestId: "ensure-1",
231232 threadId: "thread-1",
232233 resumeSessionId: "opaque/hermes/session/value",
@@ -235,7 +236,7 @@ describe("T3 to Hermes v2 messages", () => { · 2 unmodified lines
235236
236237 expect(
237238 decodeResumeCursor({
238− protocolVersion: 2,
239+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
239240 sessionId: "opaque/hermes/session/value",
240241 }).sessionId,
241242 ).toBe("opaque/hermes/session/value");
@@ -243,7 +244,7 @@ describe("T3 to Hermes v2 messages", () => { · 1 unmodified line
243244
244245 it("decodes start and steering as distinct turn operations", () => {
245246 const context = {
246− protocolVersion: 2,
247+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
247248 requestId: "turn-command-1",
248249 threadId: "thread-1",
249250 sessionId: "session-1",
@@ -257,7 +258,7 @@ describe("T3 to Hermes v2 messages", () => { · 7 unmodified lines
257258
258259 it("decodes interrupt, approval, structured input, stop, and ping", () => {
259260 const turnContext = {
260− protocolVersion: 2,
261+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
261262 threadId: "thread-1",
262263 sessionId: "session-1",
263264 turnId: "turn-1",
@@ -289,7 +290,7 @@ describe("T3 to Hermes v2 messages", () => { · 25 unmodified lines
289290 expect(
290291 decodeT3Message({
291292 type: "session.stop",
292− protocolVersion: 2,
293+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
293294 requestId: "stop-1",
294295 threadId: "thread-1",
295296 sessionId: "session-1",
@@ -298,7 +299,7 @@ describe("T3 to Hermes v2 messages", () => { · 2 unmodified lines
298299 expect(
299300 decodeT3Message({
300301 type: "ping",
301− protocolVersion: 2,
302+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
302303 requestId: "ping-1",
303304 sentAt: "2026-07-23T12:00:00.000Z",
304305 }).type,
@@ -316,11 +317,103 @@ describe("T3 to Hermes v2 messages", () => { · 11 unmodified lines
316317 }),
317318 ).toThrow();
318319 });
320+
321+ it("defaults an unstated connection role to gateway", () => {
322+ // The field is about intent, not tolerance: a hello that says nothing is
323+ // the ordinary live plugin, which must never be read as a throwaway
324+ // delivery socket.
325+ const hello = decodeHello({
326+ type: "connection.hello",
327+ requestId: "hello-role-default",
328+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
329+ pluginVersion: "0.2.0",
330+ hermesVersion: "1.2.3",
331+ capabilities: {
332+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
333+ streaming: true,
334+ activity: true,
335+ approvals: true,
336+ userInput: true,
337+ attachments: false,
338+ },
339+ authentication: { type: "instance-credential", instanceId: "hermes", credential: "secret" },
340+ });
341+
342+ expect(hello.role).toBe("gateway");
343+ });
344+
345+ it("carries the home thread designation on acceptance", () => {
346+ const accepted = decodeT3Message({
347+ type: "connection.accepted",
348+ requestId: "hello-1",
349+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
350+ instanceId: "hermes",
351+ nickname: "Remote Hermes",
352+ homeThreadId: "thread-home-1",
353+ });
354+
355+ expect(accepted.type).toBe("connection.accepted");
356+ if (accepted.type === "connection.accepted") {
357+ expect(accepted.homeThreadId).toBe("thread-home-1");
358+ }
359+
360+ // Optional: a handshake whose home-thread resolution failed still accepts
361+ // the plugin rather than refusing an authenticated connection.
362+ const withoutHome = decodeT3Message({
363+ type: "connection.accepted",
364+ requestId: "hello-2",
365+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
366+ instanceId: "hermes",
367+ nickname: "Remote Hermes",
368+ });
369+ expect(withoutHome.type).toBe("connection.accepted");
370+ });
371+});
372+
373+describe("Hermes home deliveries", () => {
374+ const delivery = {
375+ type: "home.deliver",
376+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
377+ deliveryId: "delivery-1",
378+ threadId: "thread-home-1",
379+ kind: "cron",
380+ label: "Cron: daily-digest",
381+ text: "Your digest is ready.",
382+ createdAt: "2026-07-25T12:00:00.000Z",
383+ } as const;
384+
385+ it("decodes a delivery and its acknowledgement", () => {
386+ const decoded = decodePluginMessage(delivery);
387+ expect(decoded.type).toBe("home.deliver");
388+ if (decoded.type === "home.deliver") {
389+ expect(decoded.kind).toBe("cron");
390+ expect(decoded.deliveryId).toBe("delivery-1");
391+ }
392+
393+ const ack = decodeT3Message({
394+ type: "home.deliver.ack",
395+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
396+ deliveryId: "delivery-1",
397+ });
398+ expect(ack.type).toBe("home.deliver.ack");
399+ });
400+
401+ it("requires a delivery id, since it is the dedupe key for retries", () => {
402+ expect(() => decodePluginMessage({ ...delivery, deliveryId: "" })).toThrow();
403+ });
404+
405+ it("rejects an unknown delivery kind rather than guessing a badge", () => {
406+ expect(() => decodePluginMessage({ ...delivery, kind: "surprise" })).toThrow();
407+ });
408+
409+ it("rejects empty delivery text", () => {
410+ expect(() => decodePluginMessage({ ...delivery, text: "" })).toThrow();
411+ });
319412 });
320413
321−describe("Hermes to T3 v2 events", () => {
414+describe("Hermes to T3 events", () => {
322415 const turnContext = {
323− protocolVersion: 2,
416+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
324417 threadId: "thread-1",
325418 sessionId: "session-1",
326419 turnId: "turn-1",
@@ -329,7 +422,7 @@ describe("Hermes to T3 v2 events", () => { · 2 unmodified lines
329422 it("decodes session readiness, turn start, streaming text, and completion", () => {
330423 const legacyReady = decodePluginMessage({
331424 type: "session.ready",
332− protocolVersion: 2,
425+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
333426 requestId: "ensure-1",
334427 threadId: "thread-1",
335428 sessionId: "session-1",
@@ -338,7 +431,7 @@ describe("Hermes to T3 v2 events", () => { · 2 unmodified lines
338431 expect(legacyReady.type).toBe("session.ready");
339432 const activeReady = decodePluginMessage({
340433 type: "session.ready",
341− protocolVersion: 2,
434+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
342435 requestId: "ensure-2",
343436 threadId: "thread-1",
344437 sessionId: "session-1",
packages/contracts/src/hermesGateway.ts+98−3
@@ -7,6 +7,7 @@ · 6 unmodified lines
77 *
88 * @module hermesGateway
99 */
10+import * as Effect from "effect/Effect";
1011 import * as Schema from "effect/Schema";
1112
1213 import {
@@ -21,7 +22,7 @@ import { ProviderApprovalDecision, ProviderUserInputAnswers } from "./orchestrat · 8 unmodified lines
2122 import { ProviderInstanceId } from "./providerInstance.ts";
2223 import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts";
2324
24−export const HERMES_GATEWAY_PROTOCOL_VERSION = 2 as const;
25+export const HERMES_GATEWAY_PROTOCOL_VERSION = 3 as const;
2526
2627 export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION);
2728 export type HermesGatewayProtocolVersion = typeof HermesGatewayProtocolVersion.Type;
@@ -87,7 +88,7 @@ export type HermesGatewayCapabilities = typeof HermesGatewayCapabilities.Type; · 59 unmodified lines
8788 *
8889 * This deliberately permits capability shapes from a newer protocol so T3 can
8990 * return a structured `version-incompatible` rejection instead of failing the
90− * WebSocket frame decoder. Accepted v2 connections must subsequently validate
91+ * WebSocket frame decoder. Accepted connections must subsequently validate
9192 * this advertisement with `HermesGatewayCapabilities`.
9293 */
9394 export const HermesGatewayHelloCapabilities = Schema.Struct({
@@ -256,10 +257,23 @@ export const HermesGatewayAuthentication = Schema.Union([ · 162 unmodified lines
256257 ]);
257258 export type HermesGatewayAuthentication = typeof HermesGatewayAuthentication.Type;
258259
260+/**
261+ * What a connecting socket intends to be.
262+ *
263+ * `gateway` is the instance's one live plugin connection: registered under
264+ * generation fencing, pinged for liveness, and displacing any predecessor.
265+ * `delivery` is a short-lived socket — an out-of-process cron run dialing in
266+ * only to hand over a `home.deliver` and leave. Delivery connections are
267+ * authenticated identically but are never registered as the primary
268+ * connection, so they cannot kick a healthy gateway socket off its instance.
269+ */
270+export const HermesGatewayConnectionRole = Schema.Literals(["gateway", "delivery"]);
271+export type HermesGatewayConnectionRole = typeof HermesGatewayConnectionRole.Type;
272+
259273 /**
260274 * `protocolVersion` accepts any positive integer at the initial boundary so
261275 * T3 can reject incompatible plugins with a structured upgrade response.
262− * Once accepted, all remaining v2 frames use the literal v2 schema.
276+ * Once accepted, all remaining frames use the literal current-version schema.
263277 */
264278 export const HermesGatewayConnectionHello = Schema.Struct({
265279 type: Schema.Literal("connection.hello"),
@@ -278,6 +292,12 @@ export const HermesGatewayConnectionHello = Schema.Struct({ · 12 unmodified lines
278292 * value degrades to the generic label rather than failing the handshake.
279293 */
280294 model: Schema.optional(TrimmedNonEmptyString),
295+ /**
296+ * Defaults to `"gateway"` on decode so the field stays honest about intent
297+ * rather than making every caller repeat the common case. v3 requires both
298+ * sides updated regardless, so this default is ergonomics, not tolerance.
299+ */
300+ role: HermesGatewayConnectionRole.pipe(Schema.withDecodingDefault(Effect.succeed("gateway"))),
281301 });
282302 export type HermesGatewayConnectionHello = typeof HermesGatewayConnectionHello.Type;
283303
@@ -288,6 +308,17 @@ export const HermesGatewayConnectionAccepted = Schema.Struct({ · 4 unmodified lines
288308 instanceId: ProviderInstanceId,
289309 nickname: HermesGatewayNickname,
290310 credential: Schema.optional(HermesGatewayCredential),
311+ /**
312+ * The instance's durable home thread — where Hermes' proactive output lands
313+ * when nothing named a destination. Sent on every successful handshake so
314+ * the plugin reconciles its `T3_HOME_CHANNEL` cache each connect; T3's
315+ * settings blob is the authoritative designation.
316+ *
317+ * Optional because resolving it must never fail a handshake: if the thread
318+ * could not be created this connect, the plugin keeps whatever it had and
319+ * reconciles on the next one.
320+ */
321+ homeThreadId: Schema.optional(ThreadId),
291322 });
292323 export type HermesGatewayConnectionAccepted = typeof HermesGatewayConnectionAccepted.Type;
293324
@@ -633,6 +664,68 @@ export const HermesGatewayPong = Schema.Struct({ · 339 unmodified lines
633664 });
634665 export type HermesGatewayPong = typeof HermesGatewayPong.Type;
635666
667+/**
668+ * Plugin-minted, stable across retries. T3 dedupes on it, which is what makes
669+ * the plugin's queue safe to flush more than once.
670+ */
671+export const HermesGatewayDeliveryId = TrimmedNonEmptyString.pipe(
672+ Schema.brand("HermesGatewayDeliveryId"),
673+);
674+export type HermesGatewayDeliveryId = typeof HermesGatewayDeliveryId.Type;
675+
676+/**
677+ * What produced a home delivery. Drives both the rendered badge and whether
678+ * the delivery raises its hand: everything except `lifecycle` un-settles the
679+ * thread and pushes; gateway online/shutdown notices land quietly.
680+ *
681+ * Classification is best-effort on the plugin side — Hermes' `adapter.send()`
682+ * contract carries no structured provenance marker on every path — so a
683+ * misclassification costs a wrong badge, never a lost delivery.
684+ */
685+export const HermesGatewayHomeDeliveryKind = Schema.Literals([
686+ "cron",
687+ "message",
688+ "lifecycle",
689+ "handoff",
690+ "other",
691+]);
692+export type HermesGatewayHomeDeliveryKind = typeof HermesGatewayHomeDeliveryKind.Type;
693+
694+/**
695+ * Hermes-initiated delivery into the instance's home thread.
696+ *
697+ * Deliberately not a turn: there is no provider session, no turn id, and no
698+ * request the delivery answers. A delivery may arrive while the home thread
699+ * has a live user turn and must not disturb it.
700+ */
701+export const HermesGatewayHomeDeliver = Schema.Struct({
702+ type: Schema.Literal("home.deliver"),
703+ protocolVersion: HermesGatewayProtocolVersion,
704+ deliveryId: HermesGatewayDeliveryId,
705+ threadId: ThreadId,
706+ kind: HermesGatewayHomeDeliveryKind,
707+ /** Human source label rendered as the badge — "Cron: daily-digest". */
708+ label: TrimmedNonEmptyString.check(Schema.isMaxLength(200)),
709+ text: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(120_000)),
710+ /**
711+ * When Hermes produced the content, not when it reached T3. These diverge
712+ * whenever a queued delivery flushes after a reconnect.
713+ */
714+ createdAt: IsoDateTime,
715+});
716+export type HermesGatewayHomeDeliver = typeof HermesGatewayHomeDeliver.Type;
717+
718+/**
719+ * Sent only after the delivery is durably written. The plugin purges its
720+ * queued copy on this frame and nothing else, so acking early loses messages.
721+ */
722+export const HermesGatewayHomeDeliverAck = Schema.Struct({
723+ type: Schema.Literal("home.deliver.ack"),
724+ protocolVersion: HermesGatewayProtocolVersion,
725+ deliveryId: HermesGatewayDeliveryId,
726+});
727+export type HermesGatewayHomeDeliverAck = typeof HermesGatewayHomeDeliverAck.Type;
728+
636729 export const HermesGatewayProtocolErrorCode = Schema.Literals([
637730 "invalid-message",
638731 "unsupported-message",
@@ -666,6 +759,7 @@ export const HermesGatewayT3ToPluginMessage = Schema.Union([ · 27 unmodified lines
666759 HermesGatewayDescribeRequest,
667760 HermesGatewaySkillBodyRequest,
668761 HermesGatewayPing,
762+ HermesGatewayHomeDeliverAck,
669763 ]);
670764 export type HermesGatewayT3ToPluginMessage = typeof HermesGatewayT3ToPluginMessage.Type;
671765
@@ -690,6 +784,7 @@ export const HermesGatewayPluginToT3Message = Schema.Union([ · 18 unmodified lines
690784 HermesGatewaySkillBodyResponse,
691785 HermesGatewayPong,
692786 HermesGatewayProtocolError,
787+ HermesGatewayHomeDeliver,
693788 ]);
694789 export type HermesGatewayPluginToT3Message = typeof HermesGatewayPluginToT3Message.Type;
695790
packages/contracts/src/orchestration.ts+96−0
@@ -238,6 +238,27 @@ export type OrchestrationProject = typeof OrchestrationProject.Type; · 237 unmodified lines
238238 export const OrchestrationMessageRole = Schema.Literals(["user", "assistant", "system"]);
239239 export type OrchestrationMessageRole = typeof OrchestrationMessageRole.Type;
240240
241+/**
242+ * Provenance for a message an agent delivered on its own initiative rather
243+ * than as the answer to a turn.
244+ *
245+ * Present only on proactive deliveries (a cron result, an agent-initiated
246+ * `send_message`, a gateway lifecycle notice). Its absence is what makes a
247+ * message an ordinary one, so consumers must treat "no notification" as the
248+ * normal case and never synthesize a default.
249+ */
250+export const OrchestrationMessageNotification = Schema.Struct({
251+ kind: Schema.Literals(["cron", "message", "lifecycle", "handoff", "other"]),
252+ /** Human source label rendered as the badge — "Cron: daily-digest". */
253+ label: TrimmedNonEmptyString,
254+ /**
255+ * The delivering agent's idempotency key. Retained on the row so a replayed
256+ * delivery is recognized as one already written rather than appended twice.
257+ */
258+ deliveryId: TrimmedNonEmptyString,
259+});
260+export type OrchestrationMessageNotification = typeof OrchestrationMessageNotification.Type;
261+
241262 export const OrchestrationMessage = Schema.Struct({
242263 id: MessageId,
243264 role: OrchestrationMessageRole,
@@ -245,6 +266,11 @@ export const OrchestrationMessage = Schema.Struct({ · 1 unmodified line
245266 attachments: Schema.optional(Schema.Array(ChatAttachment)),
246267 turnId: Schema.NullOr(TurnId),
247268 streaming: Schema.Boolean,
269+ /**
270+ * Set only on proactive agent deliveries. Optional so every existing
271+ * producer, cached snapshot, and persisted row decodes unchanged.
272+ */
273+ notification: Schema.optional(OrchestrationMessageNotification),
248274 createdAt: IsoDateTime,
249275 updatedAt: IsoDateTime,
250276 });
@@ -417,6 +443,24 @@ export const OrchestrationProjectShell = Schema.Struct({ · 166 unmodified lines
417443 });
418444 export type OrchestrationProjectShell = typeof OrchestrationProjectShell.Type;
419445
446+/**
447+ * The most recent proactive delivery in a thread, flattened onto the shell.
448+ *
449+ * Shell consumers (sidebar rows, the awareness/push projection) never load a
450+ * thread's messages, so the notification provenance that lives on the message
451+ * row has to be materialized here or those surfaces cannot see it at all.
452+ * `deliveredAt` is the delivering message's `createdAt`, which is what lets a
453+ * reader decide whether the notification is still the newest thing that
454+ * happened to the thread or has been overtaken by a later turn.
455+ */
456+export const OrchestrationThreadNotificationSummary = Schema.Struct({
457+ kind: OrchestrationMessageNotification.fields.kind,
458+ label: TrimmedNonEmptyString,
459+ deliveredAt: IsoDateTime,
460+});
461+export type OrchestrationThreadNotificationSummary =
462+ typeof OrchestrationThreadNotificationSummary.Type;
463+
420464 export const OrchestrationThreadShell = Schema.Struct({
421465 id: ThreadId,
422466 projectId: ProjectId,
@@ -443,6 +487,12 @@ export const OrchestrationThreadShell = Schema.Struct({ · 20 unmodified lines
443487 hasPendingApprovals: Schema.Boolean,
444488 hasPendingUserInput: Schema.Boolean,
445489 hasActionableProposedPlan: Schema.Boolean,
490+ /**
491+ * Newest proactive delivery, or `null`/absent on every thread that has never
492+ * received one. Optional (like `snoozedUntil`) so shells produced by
493+ * pre-notification servers and every existing cached snapshot still decode.
494+ */
495+ latestNotification: Schema.optional(Schema.NullOr(OrchestrationThreadNotificationSummary)),
446496 });
447497 export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type;
448498
@@ -883,6 +933,28 @@ const ThreadActivityAppendCommand = Schema.Struct({ · 434 unmodified lines
883933 createdAt: IsoDateTime,
884934 });
885935
936+/**
937+ * A proactive delivery from a connected agent into its home thread.
938+ *
939+ * Internal-only and deliberately outside the turn machinery: there is no
940+ * provider session or turn behind it, and it may arrive while the thread has
941+ * a live user turn, which it must not disturb. `deliveryId` is the agent's
942+ * idempotency key — the decider drops a repeat rather than appending twice,
943+ * which is what lets the plugin retry an unacked delivery safely.
944+ */
945+const ThreadNotificationDeliverCommand = Schema.Struct({
946+ type: Schema.Literal("thread.notification.deliver"),
947+ commandId: CommandId,
948+ threadId: ThreadId,
949+ messageId: MessageId,
950+ deliveryId: TrimmedNonEmptyString,
951+ kind: Schema.Literals(["cron", "message", "lifecycle", "handoff", "other"]),
952+ label: TrimmedNonEmptyString,
953+ text: Schema.String,
954+ /** When the agent produced the content, which may predate its arrival. */
955+ createdAt: IsoDateTime,
956+});
957+
886958 const ThreadRevertCompleteCommand = Schema.Struct({
887959 type: Schema.Literal("thread.revert.complete"),
888960 commandId: CommandId,
@@ -899,6 +971,7 @@ const InternalOrchestrationCommand = Schema.Union([ · 10 unmodified lines
899971 ThreadProposedPlanUpsertCommand,
900972 ThreadTurnDiffCompleteCommand,
901973 ThreadActivityAppendCommand,
974+ ThreadNotificationDeliverCommand,
902975 ThreadRevertCompleteCommand,
903976 ]);
904977 export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;
@@ -936,6 +1009,7 @@ export const OrchestrationEventType = Schema.Literals([ · 31 unmodified lines
9361009 "thread.proposed-plan-upserted",
9371010 "thread.turn-diff-completed",
9381011 "thread.activity-appended",
1012+ "thread.notification-delivered",
9391013 ]);
9401014 export type OrchestrationEventType = typeof OrchestrationEventType.Type;
9411015
@@ -1179,6 +1253,23 @@ export const ThreadActivityAppendedPayload = Schema.Struct({ · 237 unmodified lines
11791253 activity: OrchestrationThreadActivity,
11801254 });
11811255
1256+/**
1257+ * A proactive agent delivery, materialized as an assistant message row.
1258+ *
1259+ * It is a real transcript message — selectable, copyable, part of history —
1260+ * distinguished only by carrying `notification` provenance. It has no
1261+ * `turnId`: nothing about it belongs to a turn, and attributing it to the
1262+ * thread's live turn would fold it away behind that turn's "Worked for …".
1263+ */
1264+export const ThreadNotificationDeliveredPayload = Schema.Struct({
1265+ threadId: ThreadId,
1266+ messageId: MessageId,
1267+ text: Schema.String,
1268+ notification: OrchestrationMessageNotification,
1269+ createdAt: IsoDateTime,
1270+ updatedAt: IsoDateTime,
1271+});
1272+
11821273 export const OrchestrationEventMetadata = Schema.Struct({
11831274 providerTurnId: Schema.optional(TrimmedNonEmptyString),
11841275 providerItemId: Schema.optional(ProviderItemId),
@@ -1331,6 +1422,11 @@ export const OrchestrationEvent = Schema.Union([ · 146 unmodified lines
13311422 type: Schema.Literal("thread.activity-appended"),
13321423 payload: ThreadActivityAppendedPayload,
13331424 }),
1425+ Schema.Struct({
1426+ ...EventBaseFields,
1427+ type: Schema.Literal("thread.notification-delivered"),
1428+ payload: ThreadNotificationDeliveredPayload,
1429+ }),
13341430 ]);
13351431 export type OrchestrationEvent = typeof OrchestrationEvent.Type;
13361432
packages/contracts/src/relay.ts+2−0
@@ -19,6 +19,8 @@ export const RelayAgentAwarenessPhase = Schema.Literals([ · 18 unmodified lines
1919 "running",
2020 "waiting_for_approval",
2121 "waiting_for_input",
22+ // A proactive agent delivery (cron result, agent-initiated message).
23+ "notification",
2224 "completed",
2325 "failed",
2426 "stale",
packages/contracts/src/server.ts+9−0
@@ -203,6 +203,15 @@ export const ServerProvider = Schema.Struct({ · 202 unmodified lines
203203 skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
204204 versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory),
205205 updateState: Schema.optionalKey(ServerProviderUpdateState),
206+ /**
207+ * The instance's designated home thread, where its agent's proactive output
208+ * lands. Clients use it to pin the thread first in the instance's list and
209+ * to withhold Delete.
210+ *
211+ * Only Hermes instances designate one; absent everywhere else, and absent on
212+ * a Hermes instance that has not completed a handshake yet.
213+ */
214+ homeThreadId: Schema.optional(ThreadId),
206215 });
207216 export type ServerProvider = typeof ServerProvider.Type;
208217
packages/contracts/src/settings.ts+13−0
@@ -429,6 +429,19 @@ export const HermesSettings = makeProviderSettingsSchema({ · 428 unmodified lines
429429 revoked: Schema.optionalKey(Schema.Boolean).pipe(
430430 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
431431 ),
432+ /**
433+ * The instance's durable home thread — the authoritative designation for
434+ * where Hermes' proactive output lands.
435+ *
436+ * Kept here rather than in a dedicated table for the same reason as the
437+ * fields above: it is per-instance enrollment metadata, and settings avoids
438+ * a migration. Absent means "not yet designated"; the next handshake
439+ * converges it on read. The plugin's `T3_HOME_CHANNEL` env var is a synced
440+ * cache of this value, never the source of truth.
441+ */
442+ homeThreadId: Schema.optionalKey(TrimmedString).pipe(
443+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
444+ ),
432445 });
433446 export type HermesSettings = typeof HermesSettings.Type;
434447
packages/shared/src/agentAwareness.test.ts+74−0
@@ -29,6 +29,8 @@ function thread( · 28 unmodified lines
2929 | "updatedAt"
3030 | "hasPendingApprovals"
3131 | "hasPendingUserInput"
32+ | "latestNotification"
33+ | "latestUserMessageAt"
3234 > {
3335 return {
3436 id: "thread-1" as ThreadId,
@@ -39,6 +41,8 @@ function thread( · 4 unmodified lines
3941 updatedAt: NOW,
4042 hasPendingApprovals: false,
4143 hasPendingUserInput: false,
44+ latestNotification: null,
45+ latestUserMessageAt: null,
4246 ...overrides,
4347 };
4448 }
@@ -102,6 +106,76 @@ describe("projectThreadAwareness", () => { · 57 unmodified lines
102106 });
103107 });
104108
109+ it("projects a fresh proactive delivery with the delivery's own label as the headline", () => {
110+ const state = projectThreadAwareness({
111+ environmentId: "env-1" as EnvironmentId,
112+ project,
113+ thread: thread({
114+ latestNotification: {
115+ kind: "cron",
116+ label: "Cron: daily-digest",
117+ deliveredAt: NOW,
118+ },
119+ }),
120+ });
121+
122+ expect(state?.phase).toBe("notification");
123+ // A generic headline would strip the only information the delivery carries.
124+ expect(state?.headline).toBe("Cron: daily-digest");
125+ });
126+
127+ it("stops reporting a delivery once later thread activity supersedes it", () => {
128+ const delivered = "2026-05-22T11:00:00.000Z";
129+ const later = "2026-05-22T11:30:00.000Z";
130+ const notification = {
131+ kind: "message" as const,
132+ label: "Hermes",
133+ deliveredAt: delivered,
134+ };
135+
136+ // A user reply after the delivery: the ordinary ladder takes over again.
137+ expect(
138+ projectThreadAwareness({
139+ environmentId: "env-1" as EnvironmentId,
140+ project,
141+ thread: thread({ latestNotification: notification, latestUserMessageAt: later }),
142+ }),
143+ ).toBeNull();
144+
145+ // A turn that started after it likewise wins — otherwise "notification"
146+ // would pin forever, since nothing ever marks it read.
147+ expect(
148+ projectThreadAwareness({
149+ environmentId: "env-1" as EnvironmentId,
150+ project,
151+ thread: thread({
152+ latestNotification: notification,
153+ latestTurn: {
154+ turnId: "turn-1" as TurnId,
155+ state: "running",
156+ requestedAt: later,
157+ startedAt: later,
158+ completedAt: null,
159+ assistantMessageId: null,
160+ },
161+ }),
162+ })?.phase,
163+ ).toBe("running");
164+ });
165+
166+ it("keeps blocked-on-you phases ahead of a delivery", () => {
167+ const state = projectThreadAwareness({
168+ environmentId: "env-1" as EnvironmentId,
169+ project,
170+ thread: thread({
171+ hasPendingApprovals: true,
172+ latestNotification: { kind: "cron", label: "Cron: nightly", deliveredAt: NOW },
173+ }),
174+ });
175+
176+ expect(state?.phase).toBe("waiting_for_approval");
177+ });
178+
105179 it("projects completed turns as completed even when teardown settled them as interrupted", () => {
106180 const finishedTurn = {
107181 turnId: "turn-1" as TurnId,
packages/shared/src/agentAwareness.ts+52−2
@@ -10,6 +10,7 @@ export type AgentAwarenessPhase = · 9 unmodified lines
1010 | "running"
1111 | "waiting_for_approval"
1212 | "waiting_for_input"
13+ | "notification"
1314 | "completed"
1415 | "failed"
1516 | "stale";
@@ -40,6 +41,8 @@ export interface ProjectThreadAwarenessInput { · 24 unmodified lines
4041 | "updatedAt"
4142 | "hasPendingApprovals"
4243 | "hasPendingUserInput"
44+ | "latestNotification"
45+ | "latestUserMessageAt"
4346 >;
4447 }
4548
@@ -66,7 +69,7 @@ export function projectThreadAwareness( · 20 unmodified lines
6669 projectTitle: project.title,
6770 threadTitle: thread.title,
6871 phase,
69− headline: headlineForPhase(phase),
72+ headline: headlineForPhase(phase, thread),
7073 ...(detail === undefined ? {} : { detail }),
7174 modelTitle: thread.modelSelection.model,
7275 updatedAt: thread.updatedAt,
@@ -83,6 +86,14 @@ function resolveThreadAwarenessPhase( · 10 unmodified lines
8386 if (thread.hasPendingUserInput) {
8487 return "waiting_for_input";
8588 }
89+ // A proactive delivery is news the user has not seen, but only while it is
90+ // still the newest thing on the thread: once a turn or a user message lands
91+ // after it, the ordinary ladder describes the thread better and pinning it
92+ // to "notification" would suppress the real completion/running state
93+ // indefinitely (there is no read state to clear it).
94+ if (isLatestNotificationCurrent(thread)) {
95+ return "notification";
96+ }
8697 if (thread.session?.status === "error" || thread.latestTurn?.state === "error") {
8798 return "failed";
8899 }
@@ -116,7 +127,42 @@ function resolveThreadAwarenessPhase( · 27 unmodified lines
116127 return null;
117128 }
118129
119−function headlineForPhase(phase: AgentAwarenessPhase): string {
130+/**
131+ * True while the thread's newest proactive delivery is also the newest thing
132+ * that happened to the thread — nothing the user sent and no turn activity
133+ * postdates it.
134+ */
135+function isLatestNotificationCurrent(thread: ProjectThreadAwarenessInput["thread"]): boolean {
136+ const notification = thread.latestNotification ?? null;
137+ if (!notification) {
138+ return false;
139+ }
140+ const deliveredAtMs = Date.parse(notification.deliveredAt);
141+ if (Number.isNaN(deliveredAtMs)) {
142+ return false;
143+ }
144+ const supersedingTimestamps = [
145+ thread.latestUserMessageAt,
146+ thread.latestTurn?.completedAt ?? null,
147+ thread.latestTurn?.startedAt ?? null,
148+ thread.latestTurn?.requestedAt ?? null,
149+ ];
150+ for (const timestamp of supersedingTimestamps) {
151+ if (timestamp === null) {
152+ continue;
153+ }
154+ const timestampMs = Date.parse(timestamp);
155+ if (!Number.isNaN(timestampMs) && timestampMs > deliveredAtMs) {
156+ return false;
157+ }
158+ }
159+ return true;
160+}
161+
162+function headlineForPhase(
163+ phase: AgentAwarenessPhase,
164+ thread: ProjectThreadAwarenessInput["thread"],
165+): string {
120166 switch (phase) {
121167 case "starting":
122168 return "Starting agent";
@@ -126,6 +172,10 @@ function headlineForPhase(phase: AgentAwarenessPhase): string { · 3 unmodified lines
126172 return "Approval needed";
127173 case "waiting_for_input":
128174 return "Waiting for input";
175+ // The delivering agent's own label ("Cron: daily-digest") is the headline:
176+ // a generic one would strip the only information the delivery carries.
177+ case "notification":
178+ return thread.latestNotification?.label ?? "New message";
129179 case "completed":
130180 return "Agent finished";
131181 case "failed":