Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@
"@pierre/diffs": "catalog:",
"effect": "catalog:",
"node-pty": "^1.1.0",
"web-push": "^3.6.7",
"yaml": "catalog:"
},
"devDependencies": {
Expand All@@ -42,6 +43,7 @@
"@t3tools/web": "workspace:*",
"@types/bun": "1.3.14",
"@types/node": "catalog:",
"@types/web-push": "^3.6.4",
"effect-acp": "workspace:*",
"effect-codex-app-server": "workspace:*",
"vite-plus": "catalog:"
Expand Down
38 changes: 36 additions & 2 deletions apps/server/src/t3x/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,8 +22,14 @@ import { ServerConfig } from "../config.ts";
import { autoResumeRouteLayer } from "./autoResume/http.ts";
import { AutoResumeReactorLive } from "./autoResume/Reactor.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./autoResume/state.ts";
import { resolveConfig as resolveWebPushConfig } from "./webPush/config.ts";
import { webPushRouteLayer } from "./webPush/http.ts";
import { WebPushReactorLive } from "./webPush/Reactor.ts";
import { makePushSubscriptionStore, PushSubscriptionStore } from "./webPush/state.ts";
import { makeWebPushVapid, WebPushVapid } from "./webPush/vapid.ts";

const AUTO_RESUME_STATE_FILENAME = "t3x-auto-resume.json";
const WEB_PUSH_STATE_FILENAME = "t3x-web-push-subscriptions.json";

/** Wires the durable store to the server state directory. */
const AutoResumeStoreLive = Layer.effect(
Expand All@@ -35,12 +41,37 @@ const AutoResumeStoreLive = Layer.effect(
}),
);

/** Web Push subscription store, wired to the server state directory. */
const PushSubscriptionStoreLive = Layer.effect(
PushSubscriptionStore,
Effect.gen(function* () {
const config = yield* ServerConfig;
const path = yield* Path.Path;
return yield* makePushSubscriptionStore(path.join(config.stateDir, WEB_PUSH_STATE_FILENAME));
}),
);

/** VAPID keypair (env override, else generated + persisted in the secret store). */
const WebPushVapidLive = Layer.effect(WebPushVapid, makeWebPushVapid(resolveWebPushConfig()));

/**
* Shared fork-local deps for the Web Push reactor AND routes. Defined once at module scope so
* both `T3xLayerLive` and `T3xRoutesLive` reference the SAME layer value — Effect memoises
* construction by layer identity across the shared MemoMap, so the reactor and the subscribe
* route mutate one store/keypair rather than racing two copies over a single file (the same
* property `AutoResumeStoreLive` relies on above).
*/
const WebPushDepsLive = Layer.mergeAll(PushSubscriptionStoreLive, WebPushVapidLive);

/**
* The single fork-local layer merged into the server. The auto-resume supervisor
* self-starts on construction; its store is provided here so `server.ts` merges only
* this one layer.
*/
export const T3xLayerLive = AutoResumeReactorLive.pipe(Layer.provide(AutoResumeStoreLive));
export const T3xLayerLive = Layer.mergeAll(
AutoResumeReactorLive.pipe(Layer.provide(AutoResumeStoreLive)),
WebPushReactorLive.pipe(Layer.provide(WebPushDepsLive)),
);

/**
* All fork-local HTTP routes, fanned in here for the same reason as `T3xLayerLive`:
Expand All@@ -66,4 +97,7 @@ export const T3xLayerLive = AutoResumeReactorLive.pipe(Layer.provide(AutoResumeS
* probes rather than importing these two layers, so treat it as a guard on the
* assumption, not proof of this file's graph.
*/
export const T3xRoutesLive = autoResumeRouteLayer.pipe(Layer.provide(AutoResumeStoreLive));
export const T3xRoutesLive = Layer.mergeAll(
autoResumeRouteLayer.pipe(Layer.provide(AutoResumeStoreLive)),
webPushRouteLayer.pipe(Layer.provide(WebPushDepsLive)),
);
145 changes: 145 additions & 0 deletions apps/server/src/t3x/webPush/Reactor.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
/**
* WebPushReactor — closed-tab notification supervisor.
*
* Self-starts one scoped fiber at layer construction (no external `.start()`, so the only
* upstream seam stays the lines already in server.ts). It taps the hot orchestration
* domain-event stream, and for each thread event recomputes the awareness phase from the
* projected shell (the SAME `projectThreadAwareness` the web client uses), edge-detects, and
* on a firing transition sends a Web Push to every registered subscription. Dead
* subscriptions (404/410) are pruned as they surface.
*
* De-dup with the in-page coordinator is the service worker's job: it suppresses when a tab
* is open, so this reactor always sends and the worker only shows when no tab exists.
*
* @module t3x/webPush/Reactor
*/

import type { OrchestrationEvent, OrchestrationProjectShell, ThreadId } from "@t3tools/contracts";
import { projectThreadAwareness } from "@t3tools/shared/agentAwareness";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";

import { ServerEnvironment } from "../../environment/ServerEnvironment.ts";
import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
import { buildAttentionPayload, createAttentionEdgeTracker } from "./attention.ts";
import { resolveConfig } from "./config.ts";
import { sendWebPush } from "./send.ts";
import { PushSubscriptionStore } from "./state.ts";
import { WebPushVapid } from "./vapid.ts";

const SEND_CONCURRENCY = 4;

/** Local mirror of the relay's `eventThreadId`, kept in-seam to avoid coupling to upstream. */
function eventThreadId(event: OrchestrationEvent): ThreadId | null {
const payload = event.payload as { readonly threadId?: unknown };
if (typeof payload.threadId === "string") {
return payload.threadId as ThreadId;
}
if (event.aggregateKind === "thread" && typeof event.aggregateId === "string") {
return event.aggregateId as ThreadId;
}
return null;
}

/**
* Skip events that can never change a thread's attention phase, to avoid a shell fetch +
* awareness recompute on every high-frequency domain event. Conservative denylist: anything
* not listed still flows through (correctness over perf).
*/
function isPhaseRelevantEvent(event: OrchestrationEvent): boolean {
switch (event.type) {
case "thread.meta-updated":
case "thread.runtime-mode-set":
case "thread.interaction-mode-set":
case "thread.proposed-plan-upserted":
return false;
default:
return true;
}
}

const makeSupervisor = Effect.gen(function* () {
const config = resolveConfig();
if (!config.enabled) {
yield* Effect.logInfo("t3x web-push: disabled via T3X_WEB_PUSH_ENABLED");
return;
}

const engine = yield* OrchestrationEngineService;
const snapshotQuery = yield* ProjectionSnapshotQuery;
const serverEnvironment = yield* ServerEnvironment;
const store = yield* PushSubscriptionStore;
const vapid = yield* WebPushVapid;
const tracker = createAttentionEdgeTracker();

const handleThread = (threadId: ThreadId) =>
Effect.gen(function* () {
const threadOpt = yield* snapshotQuery.getThreadShellById(threadId);
if (Option.isNone(threadOpt)) {
tracker.forget(threadId);
return;
}
const thread = threadOpt.value;
const projectOpt = yield* snapshotQuery.getProjectShellById(thread.projectId);
const environmentId = yield* serverEnvironment.getEnvironmentId;

const projectTitle = (
Option.isSome(projectOpt) ? projectOpt.value.title : "T3 Code"
) as OrchestrationProjectShell["title"];

const state = projectThreadAwareness({
environmentId,
project: { title: projectTitle },
thread,
});
// Observe unconditionally so the tracker stays primed even with no subscribers. A
// transition that occurs before the first device subscribes then still fires once a
// subscription exists, instead of being swallowed as a first-seen observation.
const kind = tracker.observe(threadId, state?.phase ?? null);
if (kind === null || state === null) {
return;
}

const subscriptions = yield* store.list;
if (subscriptions.length === 0) {
return;
}

const payload = buildAttentionPayload(state, kind);
yield* Effect.forEach(
subscriptions,
(subscription) =>
sendWebPush(vapid, subscription, payload).pipe(
Effect.flatMap((result) =>
result.expired ? store.removeByEndpoint(subscription.endpoint) : Effect.void,
),
),
{ concurrency: SEND_CONCURRENCY, discard: true },
);
});

yield* Effect.forkScoped(
Stream.runForEach(engine.streamDomainEvents, (event) => {
const threadId = eventThreadId(event);
if (threadId === null || !isPhaseRelevantEvent(event)) {
return Effect.void;
}
return handleThread(threadId).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("t3x web-push: notification handler failed", {
eventType: event.type,
cause: Cause.pretty(cause),
}),
),
);
}),
);

yield* Effect.logInfo("t3x web-push: reactor started");
});

export const WebPushReactorLive = Layer.effectDiscard(makeSupervisor);
113 changes: 113 additions & 0 deletions apps/server/src/t3x/webPush/attention.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
import type { AgentAwarenessState } from "@t3tools/shared/agentAwareness";
import { describe, expect, it } from "vite-plus/test";

import {
attentionKey,
attentionKindForEdge,
buildAttentionPayload,
createAttentionEdgeTracker,
} from "./attention.ts";

describe("attentionKindForEdge", () => {
it("announces waiting phases on entry regardless of the previous phase", () => {
expect(attentionKindForEdge("running", "waiting_for_approval")).toBe("waiting_for_approval");
expect(attentionKindForEdge("completed", "waiting_for_input")).toBe("waiting_for_input");
});

it("announces completed only out of running", () => {
expect(attentionKindForEdge("running", "completed")).toBe("completed");
expect(attentionKindForEdge("starting", "completed")).toBeNull();
expect(attentionKindForEdge(null, "completed")).toBeNull();
});

it("stays silent for non-attention phases", () => {
expect(attentionKindForEdge("waiting_for_input", "running")).toBeNull();
expect(attentionKindForEdge("running", "starting")).toBeNull();
expect(attentionKindForEdge("running", "failed")).toBeNull();
expect(attentionKindForEdge("running", "stale")).toBeNull();
});
});

describe("createAttentionEdgeTracker", () => {
it("never fires on a thread's first observation", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "waiting_for_input")).toBeNull();
});

it("fires completed only on running -> completed", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "running")).toBeNull(); // first-seen
expect(tracker.observe("t1", "completed")).toBe("completed");
});

it("does not fire completed when the thread was never seen running", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "starting")).toBeNull(); // first-seen
expect(tracker.observe("t1", "completed")).toBeNull(); // starting -> completed
});

it("fires waiting_for_input on entry after a prior observation", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "running")).toBeNull();
expect(tracker.observe("t1", "waiting_for_input")).toBe("waiting_for_input");
});

it("does not re-fire while the phase is unchanged", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t1", "waiting_for_approval")).toBe("waiting_for_approval");
expect(tracker.observe("t1", "waiting_for_approval")).toBeNull();
});

it("treats a null phase as an observation that never fires", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t1", null)).toBeNull();
// Coming back to completed from a null observation is not a running->completed edge.
expect(tracker.observe("t1", "completed")).toBeNull();
});

it("tracks threads independently", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t2", "completed")).toBeNull(); // t2 first-seen
expect(tracker.observe("t1", "completed")).toBe("completed");
});

it("re-arms first-seen after forget", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
tracker.forget("t1");
// First observation again -> no fire even though it's a completed value.
expect(tracker.observe("t1", "completed")).toBeNull();
});
});

describe("buildAttentionPayload", () => {
it("maps an awareness state + edge into the service-worker payload", () => {
const state = {
environmentId: "env-1",
threadId: "thread-1",
projectTitle: "My Project",
threadTitle: "Fix the bug",
phase: "waiting_for_input",
headline: "Waiting for input",
modelTitle: "claude",
updatedAt: "2026-07-27T00:00:00.000Z",
deepLink: "/threads/env-1/thread-1",
} as unknown as AgentAwarenessState;

expect(buildAttentionPayload(state, "waiting_for_input")).toEqual({
title: "Waiting for input",
body: "My Project · Fix the bug",
key: "env-1::thread-1",
environmentId: "env-1",
threadId: "thread-1",
kind: "waiting_for_input",
});
});

it("keys by environment + thread", () => {
expect(attentionKey("env-1", "thread-1")).toBe("env-1::thread-1");
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@
"@pierre/diffs": "catalog:",
"effect": "catalog:",
"node-pty": "^1.1.0",
"web-push": "^3.6.7",
"yaml": "catalog:"
},
"devDependencies": {
Expand All@@ -42,6 +43,7 @@
"@t3tools/web": "workspace:*",
"@types/bun": "1.3.14",
"@types/node": "catalog:",
"@types/web-push": "^3.6.4",
"effect-acp": "workspace:*",
"effect-codex-app-server": "workspace:*",
"vite-plus": "catalog:"
Expand Down
38 changes: 36 additions & 2 deletions apps/server/src/t3x/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,8 +22,14 @@ import { ServerConfig } from "../config.ts";
import { autoResumeRouteLayer } from "./autoResume/http.ts";
import { AutoResumeReactorLive } from "./autoResume/Reactor.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./autoResume/state.ts";
import { resolveConfig as resolveWebPushConfig } from "./webPush/config.ts";
import { webPushRouteLayer } from "./webPush/http.ts";
import { WebPushReactorLive } from "./webPush/Reactor.ts";
import { makePushSubscriptionStore, PushSubscriptionStore } from "./webPush/state.ts";
import { makeWebPushVapid, WebPushVapid } from "./webPush/vapid.ts";

const AUTO_RESUME_STATE_FILENAME = "t3x-auto-resume.json";
const WEB_PUSH_STATE_FILENAME = "t3x-web-push-subscriptions.json";

/** Wires the durable store to the server state directory. */
const AutoResumeStoreLive = Layer.effect(
Expand All@@ -35,12 +41,37 @@ const AutoResumeStoreLive = Layer.effect(
}),
);

/** Web Push subscription store, wired to the server state directory. */
const PushSubscriptionStoreLive = Layer.effect(
PushSubscriptionStore,
Effect.gen(function* () {
const config = yield* ServerConfig;
const path = yield* Path.Path;
return yield* makePushSubscriptionStore(path.join(config.stateDir, WEB_PUSH_STATE_FILENAME));
}),
);

/** VAPID keypair (env override, else generated + persisted in the secret store). */
const WebPushVapidLive = Layer.effect(WebPushVapid, makeWebPushVapid(resolveWebPushConfig()));

/**
* Shared fork-local deps for the Web Push reactor AND routes. Defined once at module scope so
* both `T3xLayerLive` and `T3xRoutesLive` reference the SAME layer value — Effect memoises
* construction by layer identity across the shared MemoMap, so the reactor and the subscribe
* route mutate one store/keypair rather than racing two copies over a single file (the same
* property `AutoResumeStoreLive` relies on above).
*/
const WebPushDepsLive = Layer.mergeAll(PushSubscriptionStoreLive, WebPushVapidLive);

/**
* The single fork-local layer merged into the server. The auto-resume supervisor
* self-starts on construction; its store is provided here so `server.ts` merges only
* this one layer.
*/
export const T3xLayerLive = AutoResumeReactorLive.pipe(Layer.provide(AutoResumeStoreLive));
export const T3xLayerLive = Layer.mergeAll(
AutoResumeReactorLive.pipe(Layer.provide(AutoResumeStoreLive)),
WebPushReactorLive.pipe(Layer.provide(WebPushDepsLive)),
);

/**
* All fork-local HTTP routes, fanned in here for the same reason as `T3xLayerLive`:
Expand All@@ -66,4 +97,7 @@ export const T3xLayerLive = AutoResumeReactorLive.pipe(Layer.provide(AutoResumeS
* probes rather than importing these two layers, so treat it as a guard on the
* assumption, not proof of this file's graph.
*/
export const T3xRoutesLive = autoResumeRouteLayer.pipe(Layer.provide(AutoResumeStoreLive));
export const T3xRoutesLive = Layer.mergeAll(
autoResumeRouteLayer.pipe(Layer.provide(AutoResumeStoreLive)),
webPushRouteLayer.pipe(Layer.provide(WebPushDepsLive)),
);
145 changes: 145 additions & 0 deletions apps/server/src/t3x/webPush/Reactor.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
/**
* WebPushReactor — closed-tab notification supervisor.
*
* Self-starts one scoped fiber at layer construction (no external `.start()`, so the only
* upstream seam stays the lines already in server.ts). It taps the hot orchestration
* domain-event stream, and for each thread event recomputes the awareness phase from the
* projected shell (the SAME `projectThreadAwareness` the web client uses), edge-detects, and
* on a firing transition sends a Web Push to every registered subscription. Dead
* subscriptions (404/410) are pruned as they surface.
*
* De-dup with the in-page coordinator is the service worker's job: it suppresses when a tab
* is open, so this reactor always sends and the worker only shows when no tab exists.
*
* @module t3x/webPush/Reactor
*/

import type { OrchestrationEvent, OrchestrationProjectShell, ThreadId } from "@t3tools/contracts";
import { projectThreadAwareness } from "@t3tools/shared/agentAwareness";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";

import { ServerEnvironment } from "../../environment/ServerEnvironment.ts";
import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
import { buildAttentionPayload, createAttentionEdgeTracker } from "./attention.ts";
import { resolveConfig } from "./config.ts";
import { sendWebPush } from "./send.ts";
import { PushSubscriptionStore } from "./state.ts";
import { WebPushVapid } from "./vapid.ts";

const SEND_CONCURRENCY = 4;

/** Local mirror of the relay's `eventThreadId`, kept in-seam to avoid coupling to upstream. */
function eventThreadId(event: OrchestrationEvent): ThreadId | null {
const payload = event.payload as { readonly threadId?: unknown };
if (typeof payload.threadId === "string") {
return payload.threadId as ThreadId;
}
if (event.aggregateKind === "thread" && typeof event.aggregateId === "string") {
return event.aggregateId as ThreadId;
}
return null;
}

/**
* Skip events that can never change a thread's attention phase, to avoid a shell fetch +
* awareness recompute on every high-frequency domain event. Conservative denylist: anything
* not listed still flows through (correctness over perf).
*/
function isPhaseRelevantEvent(event: OrchestrationEvent): boolean {
switch (event.type) {
case "thread.meta-updated":
case "thread.runtime-mode-set":
case "thread.interaction-mode-set":
case "thread.proposed-plan-upserted":
return false;
default:
return true;
}
}

const makeSupervisor = Effect.gen(function* () {
const config = resolveConfig();
if (!config.enabled) {
yield* Effect.logInfo("t3x web-push: disabled via T3X_WEB_PUSH_ENABLED");
return;
}

const engine = yield* OrchestrationEngineService;
const snapshotQuery = yield* ProjectionSnapshotQuery;
const serverEnvironment = yield* ServerEnvironment;
const store = yield* PushSubscriptionStore;
const vapid = yield* WebPushVapid;
const tracker = createAttentionEdgeTracker();

const handleThread = (threadId: ThreadId) =>
Effect.gen(function* () {
const threadOpt = yield* snapshotQuery.getThreadShellById(threadId);
if (Option.isNone(threadOpt)) {
tracker.forget(threadId);
return;
}
const thread = threadOpt.value;
const projectOpt = yield* snapshotQuery.getProjectShellById(thread.projectId);
const environmentId = yield* serverEnvironment.getEnvironmentId;

const projectTitle = (
Option.isSome(projectOpt) ? projectOpt.value.title : "T3 Code"
) as OrchestrationProjectShell["title"];

const state = projectThreadAwareness({
environmentId,
project: { title: projectTitle },
thread,
});
// Observe unconditionally so the tracker stays primed even with no subscribers. A
// transition that occurs before the first device subscribes then still fires once a
// subscription exists, instead of being swallowed as a first-seen observation.
const kind = tracker.observe(threadId, state?.phase ?? null);
if (kind === null || state === null) {
return;
}

const subscriptions = yield* store.list;
if (subscriptions.length === 0) {
return;
}

const payload = buildAttentionPayload(state, kind);
yield* Effect.forEach(
subscriptions,
(subscription) =>
sendWebPush(vapid, subscription, payload).pipe(
Effect.flatMap((result) =>
result.expired ? store.removeByEndpoint(subscription.endpoint) : Effect.void,
),
),
{ concurrency: SEND_CONCURRENCY, discard: true },
);
});

yield* Effect.forkScoped(
Stream.runForEach(engine.streamDomainEvents, (event) => {
const threadId = eventThreadId(event);
if (threadId === null || !isPhaseRelevantEvent(event)) {
return Effect.void;
}
return handleThread(threadId).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("t3x web-push: notification handler failed", {
eventType: event.type,
cause: Cause.pretty(cause),
}),
),
);
}),
);

yield* Effect.logInfo("t3x web-push: reactor started");
});

export const WebPushReactorLive = Layer.effectDiscard(makeSupervisor);
113 changes: 113 additions & 0 deletions apps/server/src/t3x/webPush/attention.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
import type { AgentAwarenessState } from "@t3tools/shared/agentAwareness";
import { describe, expect, it } from "vite-plus/test";

import {
attentionKey,
attentionKindForEdge,
buildAttentionPayload,
createAttentionEdgeTracker,
} from "./attention.ts";

describe("attentionKindForEdge", () => {
it("announces waiting phases on entry regardless of the previous phase", () => {
expect(attentionKindForEdge("running", "waiting_for_approval")).toBe("waiting_for_approval");
expect(attentionKindForEdge("completed", "waiting_for_input")).toBe("waiting_for_input");
});

it("announces completed only out of running", () => {
expect(attentionKindForEdge("running", "completed")).toBe("completed");
expect(attentionKindForEdge("starting", "completed")).toBeNull();
expect(attentionKindForEdge(null, "completed")).toBeNull();
});

it("stays silent for non-attention phases", () => {
expect(attentionKindForEdge("waiting_for_input", "running")).toBeNull();
expect(attentionKindForEdge("running", "starting")).toBeNull();
expect(attentionKindForEdge("running", "failed")).toBeNull();
expect(attentionKindForEdge("running", "stale")).toBeNull();
});
});

describe("createAttentionEdgeTracker", () => {
it("never fires on a thread's first observation", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "waiting_for_input")).toBeNull();
});

it("fires completed only on running -> completed", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "running")).toBeNull(); // first-seen
expect(tracker.observe("t1", "completed")).toBe("completed");
});

it("does not fire completed when the thread was never seen running", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "starting")).toBeNull(); // first-seen
expect(tracker.observe("t1", "completed")).toBeNull(); // starting -> completed
});

it("fires waiting_for_input on entry after a prior observation", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "running")).toBeNull();
expect(tracker.observe("t1", "waiting_for_input")).toBe("waiting_for_input");
});

it("does not re-fire while the phase is unchanged", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t1", "waiting_for_approval")).toBe("waiting_for_approval");
expect(tracker.observe("t1", "waiting_for_approval")).toBeNull();
});

it("treats a null phase as an observation that never fires", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t1", null)).toBeNull();
// Coming back to completed from a null observation is not a running->completed edge.
expect(tracker.observe("t1", "completed")).toBeNull();
});

it("tracks threads independently", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t2", "completed")).toBeNull(); // t2 first-seen
expect(tracker.observe("t1", "completed")).toBe("completed");
});

it("re-arms first-seen after forget", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
tracker.forget("t1");
// First observation again -> no fire even though it's a completed value.
expect(tracker.observe("t1", "completed")).toBeNull();
});
});

describe("buildAttentionPayload", () => {
it("maps an awareness state + edge into the service-worker payload", () => {
const state = {
environmentId: "env-1",
threadId: "thread-1",
projectTitle: "My Project",
threadTitle: "Fix the bug",
phase: "waiting_for_input",
headline: "Waiting for input",
modelTitle: "claude",
updatedAt: "2026-07-27T00:00:00.000Z",
deepLink: "/threads/env-1/thread-1",
} as unknown as AgentAwarenessState;

expect(buildAttentionPayload(state, "waiting_for_input")).toEqual({
title: "Waiting for input",
body: "My Project · Fix the bug",
key: "env-1::thread-1",
environmentId: "env-1",
threadId: "thread-1",
kind: "waiting_for_input",
});
});

it("keys by environment + thread", () => {
expect(attentionKey("env-1", "thread-1")).toBe("env-1::thread-1");
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@
"@pierre/diffs": "catalog:",
"effect": "catalog:",
"node-pty": "^1.1.0",
"web-push": "^3.6.7",
"yaml": "catalog:"
},
"devDependencies": {
Expand All@@ -42,6 +43,7 @@
"@t3tools/web": "workspace:*",
"@types/bun": "1.3.14",
"@types/node": "catalog:",
"@types/web-push": "^3.6.4",
"effect-acp": "workspace:*",
"effect-codex-app-server": "workspace:*",
"vite-plus": "catalog:"
Expand Down
38 changes: 36 additions & 2 deletions apps/server/src/t3x/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,8 +22,14 @@ import { ServerConfig } from "../config.ts";
import { autoResumeRouteLayer } from "./autoResume/http.ts";
import { AutoResumeReactorLive } from "./autoResume/Reactor.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./autoResume/state.ts";
import { resolveConfig as resolveWebPushConfig } from "./webPush/config.ts";
import { webPushRouteLayer } from "./webPush/http.ts";
import { WebPushReactorLive } from "./webPush/Reactor.ts";
import { makePushSubscriptionStore, PushSubscriptionStore } from "./webPush/state.ts";
import { makeWebPushVapid, WebPushVapid } from "./webPush/vapid.ts";

const AUTO_RESUME_STATE_FILENAME = "t3x-auto-resume.json";
const WEB_PUSH_STATE_FILENAME = "t3x-web-push-subscriptions.json";

/** Wires the durable store to the server state directory. */
const AutoResumeStoreLive = Layer.effect(
Expand All@@ -35,12 +41,37 @@ const AutoResumeStoreLive = Layer.effect(
}),
);

/** Web Push subscription store, wired to the server state directory. */
const PushSubscriptionStoreLive = Layer.effect(
PushSubscriptionStore,
Effect.gen(function* () {
const config = yield* ServerConfig;
const path = yield* Path.Path;
return yield* makePushSubscriptionStore(path.join(config.stateDir, WEB_PUSH_STATE_FILENAME));
}),
);

/** VAPID keypair (env override, else generated + persisted in the secret store). */
const WebPushVapidLive = Layer.effect(WebPushVapid, makeWebPushVapid(resolveWebPushConfig()));

/**
* Shared fork-local deps for the Web Push reactor AND routes. Defined once at module scope so
* both `T3xLayerLive` and `T3xRoutesLive` reference the SAME layer value — Effect memoises
* construction by layer identity across the shared MemoMap, so the reactor and the subscribe
* route mutate one store/keypair rather than racing two copies over a single file (the same
* property `AutoResumeStoreLive` relies on above).
*/
const WebPushDepsLive = Layer.mergeAll(PushSubscriptionStoreLive, WebPushVapidLive);

/**
* The single fork-local layer merged into the server. The auto-resume supervisor
* self-starts on construction; its store is provided here so `server.ts` merges only
* this one layer.
*/
export const T3xLayerLive = AutoResumeReactorLive.pipe(Layer.provide(AutoResumeStoreLive));
export const T3xLayerLive = Layer.mergeAll(
AutoResumeReactorLive.pipe(Layer.provide(AutoResumeStoreLive)),
WebPushReactorLive.pipe(Layer.provide(WebPushDepsLive)),
);

/**
* All fork-local HTTP routes, fanned in here for the same reason as `T3xLayerLive`:
Expand All@@ -66,4 +97,7 @@ export const T3xLayerLive = AutoResumeReactorLive.pipe(Layer.provide(AutoResumeS
* probes rather than importing these two layers, so treat it as a guard on the
* assumption, not proof of this file's graph.
*/
export const T3xRoutesLive = autoResumeRouteLayer.pipe(Layer.provide(AutoResumeStoreLive));
export const T3xRoutesLive = Layer.mergeAll(
autoResumeRouteLayer.pipe(Layer.provide(AutoResumeStoreLive)),
webPushRouteLayer.pipe(Layer.provide(WebPushDepsLive)),
);
145 changes: 145 additions & 0 deletions apps/server/src/t3x/webPush/Reactor.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
/**
* WebPushReactor — closed-tab notification supervisor.
*
* Self-starts one scoped fiber at layer construction (no external `.start()`, so the only
* upstream seam stays the lines already in server.ts). It taps the hot orchestration
* domain-event stream, and for each thread event recomputes the awareness phase from the
* projected shell (the SAME `projectThreadAwareness` the web client uses), edge-detects, and
* on a firing transition sends a Web Push to every registered subscription. Dead
* subscriptions (404/410) are pruned as they surface.
*
* De-dup with the in-page coordinator is the service worker's job: it suppresses when a tab
* is open, so this reactor always sends and the worker only shows when no tab exists.
*
* @module t3x/webPush/Reactor
*/

import type { OrchestrationEvent, OrchestrationProjectShell, ThreadId } from "@t3tools/contracts";
import { projectThreadAwareness } from "@t3tools/shared/agentAwareness";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";

import { ServerEnvironment } from "../../environment/ServerEnvironment.ts";
import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
import { buildAttentionPayload, createAttentionEdgeTracker } from "./attention.ts";
import { resolveConfig } from "./config.ts";
import { sendWebPush } from "./send.ts";
import { PushSubscriptionStore } from "./state.ts";
import { WebPushVapid } from "./vapid.ts";

const SEND_CONCURRENCY = 4;

/** Local mirror of the relay's `eventThreadId`, kept in-seam to avoid coupling to upstream. */
function eventThreadId(event: OrchestrationEvent): ThreadId | null {
const payload = event.payload as { readonly threadId?: unknown };
if (typeof payload.threadId === "string") {
return payload.threadId as ThreadId;
}
if (event.aggregateKind === "thread" && typeof event.aggregateId === "string") {
return event.aggregateId as ThreadId;
}
return null;
}

/**
* Skip events that can never change a thread's attention phase, to avoid a shell fetch +
* awareness recompute on every high-frequency domain event. Conservative denylist: anything
* not listed still flows through (correctness over perf).
*/
function isPhaseRelevantEvent(event: OrchestrationEvent): boolean {
switch (event.type) {
case "thread.meta-updated":
case "thread.runtime-mode-set":
case "thread.interaction-mode-set":
case "thread.proposed-plan-upserted":
return false;
default:
return true;
}
}

const makeSupervisor = Effect.gen(function* () {
const config = resolveConfig();
if (!config.enabled) {
yield* Effect.logInfo("t3x web-push: disabled via T3X_WEB_PUSH_ENABLED");
return;
}

const engine = yield* OrchestrationEngineService;
const snapshotQuery = yield* ProjectionSnapshotQuery;
const serverEnvironment = yield* ServerEnvironment;
const store = yield* PushSubscriptionStore;
const vapid = yield* WebPushVapid;
const tracker = createAttentionEdgeTracker();

const handleThread = (threadId: ThreadId) =>
Effect.gen(function* () {
const threadOpt = yield* snapshotQuery.getThreadShellById(threadId);
if (Option.isNone(threadOpt)) {
tracker.forget(threadId);
return;
}
const thread = threadOpt.value;
const projectOpt = yield* snapshotQuery.getProjectShellById(thread.projectId);
const environmentId = yield* serverEnvironment.getEnvironmentId;

const projectTitle = (
Option.isSome(projectOpt) ? projectOpt.value.title : "T3 Code"
) as OrchestrationProjectShell["title"];

const state = projectThreadAwareness({
environmentId,
project: { title: projectTitle },
thread,
});
// Observe unconditionally so the tracker stays primed even with no subscribers. A
// transition that occurs before the first device subscribes then still fires once a
// subscription exists, instead of being swallowed as a first-seen observation.
const kind = tracker.observe(threadId, state?.phase ?? null);
if (kind === null || state === null) {
return;
}

const subscriptions = yield* store.list;
if (subscriptions.length === 0) {
return;
}

const payload = buildAttentionPayload(state, kind);
yield* Effect.forEach(
subscriptions,
(subscription) =>
sendWebPush(vapid, subscription, payload).pipe(
Effect.flatMap((result) =>
result.expired ? store.removeByEndpoint(subscription.endpoint) : Effect.void,
),
),
{ concurrency: SEND_CONCURRENCY, discard: true },
);
});

yield* Effect.forkScoped(
Stream.runForEach(engine.streamDomainEvents, (event) => {
const threadId = eventThreadId(event);
if (threadId === null || !isPhaseRelevantEvent(event)) {
return Effect.void;
}
return handleThread(threadId).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("t3x web-push: notification handler failed", {
eventType: event.type,
cause: Cause.pretty(cause),
}),
),
);
}),
);

yield* Effect.logInfo("t3x web-push: reactor started");
});

export const WebPushReactorLive = Layer.effectDiscard(makeSupervisor);
113 changes: 113 additions & 0 deletions apps/server/src/t3x/webPush/attention.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
import type { AgentAwarenessState } from "@t3tools/shared/agentAwareness";
import { describe, expect, it } from "vite-plus/test";

import {
attentionKey,
attentionKindForEdge,
buildAttentionPayload,
createAttentionEdgeTracker,
} from "./attention.ts";

describe("attentionKindForEdge", () => {
it("announces waiting phases on entry regardless of the previous phase", () => {
expect(attentionKindForEdge("running", "waiting_for_approval")).toBe("waiting_for_approval");
expect(attentionKindForEdge("completed", "waiting_for_input")).toBe("waiting_for_input");
});

it("announces completed only out of running", () => {
expect(attentionKindForEdge("running", "completed")).toBe("completed");
expect(attentionKindForEdge("starting", "completed")).toBeNull();
expect(attentionKindForEdge(null, "completed")).toBeNull();
});

it("stays silent for non-attention phases", () => {
expect(attentionKindForEdge("waiting_for_input", "running")).toBeNull();
expect(attentionKindForEdge("running", "starting")).toBeNull();
expect(attentionKindForEdge("running", "failed")).toBeNull();
expect(attentionKindForEdge("running", "stale")).toBeNull();
});
});

describe("createAttentionEdgeTracker", () => {
it("never fires on a thread's first observation", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "waiting_for_input")).toBeNull();
});

it("fires completed only on running -> completed", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "running")).toBeNull(); // first-seen
expect(tracker.observe("t1", "completed")).toBe("completed");
});

it("does not fire completed when the thread was never seen running", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "starting")).toBeNull(); // first-seen
expect(tracker.observe("t1", "completed")).toBeNull(); // starting -> completed
});

it("fires waiting_for_input on entry after a prior observation", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "running")).toBeNull();
expect(tracker.observe("t1", "waiting_for_input")).toBe("waiting_for_input");
});

it("does not re-fire while the phase is unchanged", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t1", "waiting_for_approval")).toBe("waiting_for_approval");
expect(tracker.observe("t1", "waiting_for_approval")).toBeNull();
});

it("treats a null phase as an observation that never fires", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t1", null)).toBeNull();
// Coming back to completed from a null observation is not a running->completed edge.
expect(tracker.observe("t1", "completed")).toBeNull();
});

it("tracks threads independently", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t2", "completed")).toBeNull(); // t2 first-seen
expect(tracker.observe("t1", "completed")).toBe("completed");
});

it("re-arms first-seen after forget", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
tracker.forget("t1");
// First observation again -> no fire even though it's a completed value.
expect(tracker.observe("t1", "completed")).toBeNull();
});
});

describe("buildAttentionPayload", () => {
it("maps an awareness state + edge into the service-worker payload", () => {
const state = {
environmentId: "env-1",
threadId: "thread-1",
projectTitle: "My Project",
threadTitle: "Fix the bug",
phase: "waiting_for_input",
headline: "Waiting for input",
modelTitle: "claude",
updatedAt: "2026-07-27T00:00:00.000Z",
deepLink: "/threads/env-1/thread-1",
} as unknown as AgentAwarenessState;

expect(buildAttentionPayload(state, "waiting_for_input")).toEqual({
title: "Waiting for input",
body: "My Project · Fix the bug",
key: "env-1::thread-1",
environmentId: "env-1",
threadId: "thread-1",
kind: "waiting_for_input",
});
});

it("keys by environment + thread", () => {
expect(attentionKey("env-1", "thread-1")).toBe("env-1::thread-1");
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@
"@pierre/diffs": "catalog:",
"effect": "catalog:",
"node-pty": "^1.1.0",
"web-push": "^3.6.7",
"yaml": "catalog:"
},
"devDependencies": {
Expand All@@ -42,6 +43,7 @@
"@t3tools/web": "workspace:*",
"@types/bun": "1.3.14",
"@types/node": "catalog:",
"@types/web-push": "^3.6.4",
"effect-acp": "workspace:*",
"effect-codex-app-server": "workspace:*",
"vite-plus": "catalog:"
Expand Down
38 changes: 36 additions & 2 deletions apps/server/src/t3x/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,8 +22,14 @@ import { ServerConfig } from "../config.ts";
import { autoResumeRouteLayer } from "./autoResume/http.ts";
import { AutoResumeReactorLive } from "./autoResume/Reactor.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./autoResume/state.ts";
import { resolveConfig as resolveWebPushConfig } from "./webPush/config.ts";
import { webPushRouteLayer } from "./webPush/http.ts";
import { WebPushReactorLive } from "./webPush/Reactor.ts";
import { makePushSubscriptionStore, PushSubscriptionStore } from "./webPush/state.ts";
import { makeWebPushVapid, WebPushVapid } from "./webPush/vapid.ts";

const AUTO_RESUME_STATE_FILENAME = "t3x-auto-resume.json";
const WEB_PUSH_STATE_FILENAME = "t3x-web-push-subscriptions.json";

/** Wires the durable store to the server state directory. */
const AutoResumeStoreLive = Layer.effect(
Expand All@@ -35,12 +41,37 @@ const AutoResumeStoreLive = Layer.effect(
}),
);

/** Web Push subscription store, wired to the server state directory. */
const PushSubscriptionStoreLive = Layer.effect(
PushSubscriptionStore,
Effect.gen(function* () {
const config = yield* ServerConfig;
const path = yield* Path.Path;
return yield* makePushSubscriptionStore(path.join(config.stateDir, WEB_PUSH_STATE_FILENAME));
}),
);

/** VAPID keypair (env override, else generated + persisted in the secret store). */
const WebPushVapidLive = Layer.effect(WebPushVapid, makeWebPushVapid(resolveWebPushConfig()));

/**
* Shared fork-local deps for the Web Push reactor AND routes. Defined once at module scope so
* both `T3xLayerLive` and `T3xRoutesLive` reference the SAME layer value — Effect memoises
* construction by layer identity across the shared MemoMap, so the reactor and the subscribe
* route mutate one store/keypair rather than racing two copies over a single file (the same
* property `AutoResumeStoreLive` relies on above).
*/
const WebPushDepsLive = Layer.mergeAll(PushSubscriptionStoreLive, WebPushVapidLive);

/**
* The single fork-local layer merged into the server. The auto-resume supervisor
* self-starts on construction; its store is provided here so `server.ts` merges only
* this one layer.
*/
export const T3xLayerLive = AutoResumeReactorLive.pipe(Layer.provide(AutoResumeStoreLive));
export const T3xLayerLive = Layer.mergeAll(
AutoResumeReactorLive.pipe(Layer.provide(AutoResumeStoreLive)),
WebPushReactorLive.pipe(Layer.provide(WebPushDepsLive)),
);

/**
* All fork-local HTTP routes, fanned in here for the same reason as `T3xLayerLive`:
Expand All@@ -66,4 +97,7 @@ export const T3xLayerLive = AutoResumeReactorLive.pipe(Layer.provide(AutoResumeS
* probes rather than importing these two layers, so treat it as a guard on the
* assumption, not proof of this file's graph.
*/
export const T3xRoutesLive = autoResumeRouteLayer.pipe(Layer.provide(AutoResumeStoreLive));
export const T3xRoutesLive = Layer.mergeAll(
autoResumeRouteLayer.pipe(Layer.provide(AutoResumeStoreLive)),
webPushRouteLayer.pipe(Layer.provide(WebPushDepsLive)),
);
145 changes: 145 additions & 0 deletions apps/server/src/t3x/webPush/Reactor.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
/**
* WebPushReactor — closed-tab notification supervisor.
*
* Self-starts one scoped fiber at layer construction (no external `.start()`, so the only
* upstream seam stays the lines already in server.ts). It taps the hot orchestration
* domain-event stream, and for each thread event recomputes the awareness phase from the
* projected shell (the SAME `projectThreadAwareness` the web client uses), edge-detects, and
* on a firing transition sends a Web Push to every registered subscription. Dead
* subscriptions (404/410) are pruned as they surface.
*
* De-dup with the in-page coordinator is the service worker's job: it suppresses when a tab
* is open, so this reactor always sends and the worker only shows when no tab exists.
*
* @module t3x/webPush/Reactor
*/

import type { OrchestrationEvent, OrchestrationProjectShell, ThreadId } from "@t3tools/contracts";
import { projectThreadAwareness } from "@t3tools/shared/agentAwareness";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";

import { ServerEnvironment } from "../../environment/ServerEnvironment.ts";
import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
import { buildAttentionPayload, createAttentionEdgeTracker } from "./attention.ts";
import { resolveConfig } from "./config.ts";
import { sendWebPush } from "./send.ts";
import { PushSubscriptionStore } from "./state.ts";
import { WebPushVapid } from "./vapid.ts";

const SEND_CONCURRENCY = 4;

/** Local mirror of the relay's `eventThreadId`, kept in-seam to avoid coupling to upstream. */
function eventThreadId(event: OrchestrationEvent): ThreadId | null {
const payload = event.payload as { readonly threadId?: unknown };
if (typeof payload.threadId === "string") {
return payload.threadId as ThreadId;
}
if (event.aggregateKind === "thread" && typeof event.aggregateId === "string") {
return event.aggregateId as ThreadId;
}
return null;
}

/**
* Skip events that can never change a thread's attention phase, to avoid a shell fetch +
* awareness recompute on every high-frequency domain event. Conservative denylist: anything
* not listed still flows through (correctness over perf).
*/
function isPhaseRelevantEvent(event: OrchestrationEvent): boolean {
switch (event.type) {
case "thread.meta-updated":
case "thread.runtime-mode-set":
case "thread.interaction-mode-set":
case "thread.proposed-plan-upserted":
return false;
default:
return true;
}
}

const makeSupervisor = Effect.gen(function* () {
const config = resolveConfig();
if (!config.enabled) {
yield* Effect.logInfo("t3x web-push: disabled via T3X_WEB_PUSH_ENABLED");
return;
}

const engine = yield* OrchestrationEngineService;
const snapshotQuery = yield* ProjectionSnapshotQuery;
const serverEnvironment = yield* ServerEnvironment;
const store = yield* PushSubscriptionStore;
const vapid = yield* WebPushVapid;
const tracker = createAttentionEdgeTracker();

const handleThread = (threadId: ThreadId) =>
Effect.gen(function* () {
const threadOpt = yield* snapshotQuery.getThreadShellById(threadId);
if (Option.isNone(threadOpt)) {
tracker.forget(threadId);
return;
}
const thread = threadOpt.value;
const projectOpt = yield* snapshotQuery.getProjectShellById(thread.projectId);
const environmentId = yield* serverEnvironment.getEnvironmentId;

const projectTitle = (
Option.isSome(projectOpt) ? projectOpt.value.title : "T3 Code"
) as OrchestrationProjectShell["title"];

const state = projectThreadAwareness({
environmentId,
project: { title: projectTitle },
thread,
});
// Observe unconditionally so the tracker stays primed even with no subscribers. A
// transition that occurs before the first device subscribes then still fires once a
// subscription exists, instead of being swallowed as a first-seen observation.
const kind = tracker.observe(threadId, state?.phase ?? null);
if (kind === null || state === null) {
return;
}

const subscriptions = yield* store.list;
if (subscriptions.length === 0) {
return;
}

const payload = buildAttentionPayload(state, kind);
yield* Effect.forEach(
subscriptions,
(subscription) =>
sendWebPush(vapid, subscription, payload).pipe(
Effect.flatMap((result) =>
result.expired ? store.removeByEndpoint(subscription.endpoint) : Effect.void,
),
),
{ concurrency: SEND_CONCURRENCY, discard: true },
);
});

yield* Effect.forkScoped(
Stream.runForEach(engine.streamDomainEvents, (event) => {
const threadId = eventThreadId(event);
if (threadId === null || !isPhaseRelevantEvent(event)) {
return Effect.void;
}
return handleThread(threadId).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("t3x web-push: notification handler failed", {
eventType: event.type,
cause: Cause.pretty(cause),
}),
),
);
}),
);

yield* Effect.logInfo("t3x web-push: reactor started");
});

export const WebPushReactorLive = Layer.effectDiscard(makeSupervisor);
113 changes: 113 additions & 0 deletions apps/server/src/t3x/webPush/attention.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
import type { AgentAwarenessState } from "@t3tools/shared/agentAwareness";
import { describe, expect, it } from "vite-plus/test";

import {
attentionKey,
attentionKindForEdge,
buildAttentionPayload,
createAttentionEdgeTracker,
} from "./attention.ts";

describe("attentionKindForEdge", () => {
it("announces waiting phases on entry regardless of the previous phase", () => {
expect(attentionKindForEdge("running", "waiting_for_approval")).toBe("waiting_for_approval");
expect(attentionKindForEdge("completed", "waiting_for_input")).toBe("waiting_for_input");
});

it("announces completed only out of running", () => {
expect(attentionKindForEdge("running", "completed")).toBe("completed");
expect(attentionKindForEdge("starting", "completed")).toBeNull();
expect(attentionKindForEdge(null, "completed")).toBeNull();
});

it("stays silent for non-attention phases", () => {
expect(attentionKindForEdge("waiting_for_input", "running")).toBeNull();
expect(attentionKindForEdge("running", "starting")).toBeNull();
expect(attentionKindForEdge("running", "failed")).toBeNull();
expect(attentionKindForEdge("running", "stale")).toBeNull();
});
});

describe("createAttentionEdgeTracker", () => {
it("never fires on a thread's first observation", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "waiting_for_input")).toBeNull();
});

it("fires completed only on running -> completed", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "running")).toBeNull(); // first-seen
expect(tracker.observe("t1", "completed")).toBe("completed");
});

it("does not fire completed when the thread was never seen running", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "starting")).toBeNull(); // first-seen
expect(tracker.observe("t1", "completed")).toBeNull(); // starting -> completed
});

it("fires waiting_for_input on entry after a prior observation", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "running")).toBeNull();
expect(tracker.observe("t1", "waiting_for_input")).toBe("waiting_for_input");
});

it("does not re-fire while the phase is unchanged", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t1", "waiting_for_approval")).toBe("waiting_for_approval");
expect(tracker.observe("t1", "waiting_for_approval")).toBeNull();
});

it("treats a null phase as an observation that never fires", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t1", null)).toBeNull();
// Coming back to completed from a null observation is not a running->completed edge.
expect(tracker.observe("t1", "completed")).toBeNull();
});

it("tracks threads independently", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t2", "completed")).toBeNull(); // t2 first-seen
expect(tracker.observe("t1", "completed")).toBe("completed");
});

it("re-arms first-seen after forget", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
tracker.forget("t1");
// First observation again -> no fire even though it's a completed value.
expect(tracker.observe("t1", "completed")).toBeNull();
});
});

describe("buildAttentionPayload", () => {
it("maps an awareness state + edge into the service-worker payload", () => {
const state = {
environmentId: "env-1",
threadId: "thread-1",
projectTitle: "My Project",
threadTitle: "Fix the bug",
phase: "waiting_for_input",
headline: "Waiting for input",
modelTitle: "claude",
updatedAt: "2026-07-27T00:00:00.000Z",
deepLink: "/threads/env-1/thread-1",
} as unknown as AgentAwarenessState;

expect(buildAttentionPayload(state, "waiting_for_input")).toEqual({
title: "Waiting for input",
body: "My Project · Fix the bug",
key: "env-1::thread-1",
environmentId: "env-1",
threadId: "thread-1",
kind: "waiting_for_input",
});
});

it("keys by environment + thread", () => {
expect(attentionKey("env-1", "thread-1")).toBe("env-1::thread-1");
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@
"@pierre/diffs": "catalog:",
"effect": "catalog:",
"node-pty": "^1.1.0",
"web-push": "^3.6.7",
"yaml": "catalog:"
},
"devDependencies": {
Expand All@@ -42,6 +43,7 @@
"@t3tools/web": "workspace:*",
"@types/bun": "1.3.14",
"@types/node": "catalog:",
"@types/web-push": "^3.6.4",
"effect-acp": "workspace:*",
"effect-codex-app-server": "workspace:*",
"vite-plus": "catalog:"
Expand Down
38 changes: 36 additions & 2 deletions apps/server/src/t3x/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,8 +22,14 @@ import { ServerConfig } from "../config.ts";
import { autoResumeRouteLayer } from "./autoResume/http.ts";
import { AutoResumeReactorLive } from "./autoResume/Reactor.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./autoResume/state.ts";
import { resolveConfig as resolveWebPushConfig } from "./webPush/config.ts";
import { webPushRouteLayer } from "./webPush/http.ts";
import { WebPushReactorLive } from "./webPush/Reactor.ts";
import { makePushSubscriptionStore, PushSubscriptionStore } from "./webPush/state.ts";
import { makeWebPushVapid, WebPushVapid } from "./webPush/vapid.ts";

const AUTO_RESUME_STATE_FILENAME = "t3x-auto-resume.json";
const WEB_PUSH_STATE_FILENAME = "t3x-web-push-subscriptions.json";

/** Wires the durable store to the server state directory. */
const AutoResumeStoreLive = Layer.effect(
Expand All@@ -35,12 +41,37 @@ const AutoResumeStoreLive = Layer.effect(
}),
);

/** Web Push subscription store, wired to the server state directory. */
const PushSubscriptionStoreLive = Layer.effect(
PushSubscriptionStore,
Effect.gen(function* () {
const config = yield* ServerConfig;
const path = yield* Path.Path;
return yield* makePushSubscriptionStore(path.join(config.stateDir, WEB_PUSH_STATE_FILENAME));
}),
);

/** VAPID keypair (env override, else generated + persisted in the secret store). */
const WebPushVapidLive = Layer.effect(WebPushVapid, makeWebPushVapid(resolveWebPushConfig()));

/**
* Shared fork-local deps for the Web Push reactor AND routes. Defined once at module scope so
* both `T3xLayerLive` and `T3xRoutesLive` reference the SAME layer value — Effect memoises
* construction by layer identity across the shared MemoMap, so the reactor and the subscribe
* route mutate one store/keypair rather than racing two copies over a single file (the same
* property `AutoResumeStoreLive` relies on above).
*/
const WebPushDepsLive = Layer.mergeAll(PushSubscriptionStoreLive, WebPushVapidLive);

/**
* The single fork-local layer merged into the server. The auto-resume supervisor
* self-starts on construction; its store is provided here so `server.ts` merges only
* this one layer.
*/
export const T3xLayerLive = AutoResumeReactorLive.pipe(Layer.provide(AutoResumeStoreLive));
export const T3xLayerLive = Layer.mergeAll(
AutoResumeReactorLive.pipe(Layer.provide(AutoResumeStoreLive)),
WebPushReactorLive.pipe(Layer.provide(WebPushDepsLive)),
);

/**
* All fork-local HTTP routes, fanned in here for the same reason as `T3xLayerLive`:
Expand All@@ -66,4 +97,7 @@ export const T3xLayerLive = AutoResumeReactorLive.pipe(Layer.provide(AutoResumeS
* probes rather than importing these two layers, so treat it as a guard on the
* assumption, not proof of this file's graph.
*/
export const T3xRoutesLive = autoResumeRouteLayer.pipe(Layer.provide(AutoResumeStoreLive));
export const T3xRoutesLive = Layer.mergeAll(
autoResumeRouteLayer.pipe(Layer.provide(AutoResumeStoreLive)),
webPushRouteLayer.pipe(Layer.provide(WebPushDepsLive)),
);
145 changes: 145 additions & 0 deletions apps/server/src/t3x/webPush/Reactor.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
/**
* WebPushReactor — closed-tab notification supervisor.
*
* Self-starts one scoped fiber at layer construction (no external `.start()`, so the only
* upstream seam stays the lines already in server.ts). It taps the hot orchestration
* domain-event stream, and for each thread event recomputes the awareness phase from the
* projected shell (the SAME `projectThreadAwareness` the web client uses), edge-detects, and
* on a firing transition sends a Web Push to every registered subscription. Dead
* subscriptions (404/410) are pruned as they surface.
*
* De-dup with the in-page coordinator is the service worker's job: it suppresses when a tab
* is open, so this reactor always sends and the worker only shows when no tab exists.
*
* @module t3x/webPush/Reactor
*/

import type { OrchestrationEvent, OrchestrationProjectShell, ThreadId } from "@t3tools/contracts";
import { projectThreadAwareness } from "@t3tools/shared/agentAwareness";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";

import { ServerEnvironment } from "../../environment/ServerEnvironment.ts";
import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
import { buildAttentionPayload, createAttentionEdgeTracker } from "./attention.ts";
import { resolveConfig } from "./config.ts";
import { sendWebPush } from "./send.ts";
import { PushSubscriptionStore } from "./state.ts";
import { WebPushVapid } from "./vapid.ts";

const SEND_CONCURRENCY = 4;

/** Local mirror of the relay's `eventThreadId`, kept in-seam to avoid coupling to upstream. */
function eventThreadId(event: OrchestrationEvent): ThreadId | null {
const payload = event.payload as { readonly threadId?: unknown };
if (typeof payload.threadId === "string") {
return payload.threadId as ThreadId;
}
if (event.aggregateKind === "thread" && typeof event.aggregateId === "string") {
return event.aggregateId as ThreadId;
}
return null;
}

/**
* Skip events that can never change a thread's attention phase, to avoid a shell fetch +
* awareness recompute on every high-frequency domain event. Conservative denylist: anything
* not listed still flows through (correctness over perf).
*/
function isPhaseRelevantEvent(event: OrchestrationEvent): boolean {
switch (event.type) {
case "thread.meta-updated":
case "thread.runtime-mode-set":
case "thread.interaction-mode-set":
case "thread.proposed-plan-upserted":
return false;
default:
return true;
}
}

const makeSupervisor = Effect.gen(function* () {
const config = resolveConfig();
if (!config.enabled) {
yield* Effect.logInfo("t3x web-push: disabled via T3X_WEB_PUSH_ENABLED");
return;
}

const engine = yield* OrchestrationEngineService;
const snapshotQuery = yield* ProjectionSnapshotQuery;
const serverEnvironment = yield* ServerEnvironment;
const store = yield* PushSubscriptionStore;
const vapid = yield* WebPushVapid;
const tracker = createAttentionEdgeTracker();

const handleThread = (threadId: ThreadId) =>
Effect.gen(function* () {
const threadOpt = yield* snapshotQuery.getThreadShellById(threadId);
if (Option.isNone(threadOpt)) {
tracker.forget(threadId);
return;
}
const thread = threadOpt.value;
const projectOpt = yield* snapshotQuery.getProjectShellById(thread.projectId);
const environmentId = yield* serverEnvironment.getEnvironmentId;

const projectTitle = (
Option.isSome(projectOpt) ? projectOpt.value.title : "T3 Code"
) as OrchestrationProjectShell["title"];

const state = projectThreadAwareness({
environmentId,
project: { title: projectTitle },
thread,
});
// Observe unconditionally so the tracker stays primed even with no subscribers. A
// transition that occurs before the first device subscribes then still fires once a
// subscription exists, instead of being swallowed as a first-seen observation.
const kind = tracker.observe(threadId, state?.phase ?? null);
if (kind === null || state === null) {
return;
}

const subscriptions = yield* store.list;
if (subscriptions.length === 0) {
return;
}

const payload = buildAttentionPayload(state, kind);
yield* Effect.forEach(
subscriptions,
(subscription) =>
sendWebPush(vapid, subscription, payload).pipe(
Effect.flatMap((result) =>
result.expired ? store.removeByEndpoint(subscription.endpoint) : Effect.void,
),
),
{ concurrency: SEND_CONCURRENCY, discard: true },
);
});

yield* Effect.forkScoped(
Stream.runForEach(engine.streamDomainEvents, (event) => {
const threadId = eventThreadId(event);
if (threadId === null || !isPhaseRelevantEvent(event)) {
return Effect.void;
}
return handleThread(threadId).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("t3x web-push: notification handler failed", {
eventType: event.type,
cause: Cause.pretty(cause),
}),
),
);
}),
);

yield* Effect.logInfo("t3x web-push: reactor started");
});

export const WebPushReactorLive = Layer.effectDiscard(makeSupervisor);
113 changes: 113 additions & 0 deletions apps/server/src/t3x/webPush/attention.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
import type { AgentAwarenessState } from "@t3tools/shared/agentAwareness";
import { describe, expect, it } from "vite-plus/test";

import {
attentionKey,
attentionKindForEdge,
buildAttentionPayload,
createAttentionEdgeTracker,
} from "./attention.ts";

describe("attentionKindForEdge", () => {
it("announces waiting phases on entry regardless of the previous phase", () => {
expect(attentionKindForEdge("running", "waiting_for_approval")).toBe("waiting_for_approval");
expect(attentionKindForEdge("completed", "waiting_for_input")).toBe("waiting_for_input");
});

it("announces completed only out of running", () => {
expect(attentionKindForEdge("running", "completed")).toBe("completed");
expect(attentionKindForEdge("starting", "completed")).toBeNull();
expect(attentionKindForEdge(null, "completed")).toBeNull();
});

it("stays silent for non-attention phases", () => {
expect(attentionKindForEdge("waiting_for_input", "running")).toBeNull();
expect(attentionKindForEdge("running", "starting")).toBeNull();
expect(attentionKindForEdge("running", "failed")).toBeNull();
expect(attentionKindForEdge("running", "stale")).toBeNull();
});
});

describe("createAttentionEdgeTracker", () => {
it("never fires on a thread's first observation", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "waiting_for_input")).toBeNull();
});

it("fires completed only on running -> completed", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "running")).toBeNull(); // first-seen
expect(tracker.observe("t1", "completed")).toBe("completed");
});

it("does not fire completed when the thread was never seen running", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "starting")).toBeNull(); // first-seen
expect(tracker.observe("t1", "completed")).toBeNull(); // starting -> completed
});

it("fires waiting_for_input on entry after a prior observation", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "running")).toBeNull();
expect(tracker.observe("t1", "waiting_for_input")).toBe("waiting_for_input");
});

it("does not re-fire while the phase is unchanged", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t1", "waiting_for_approval")).toBe("waiting_for_approval");
expect(tracker.observe("t1", "waiting_for_approval")).toBeNull();
});

it("treats a null phase as an observation that never fires", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t1", null)).toBeNull();
// Coming back to completed from a null observation is not a running->completed edge.
expect(tracker.observe("t1", "completed")).toBeNull();
});

it("tracks threads independently", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t2", "completed")).toBeNull(); // t2 first-seen
expect(tracker.observe("t1", "completed")).toBe("completed");
});

it("re-arms first-seen after forget", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
tracker.forget("t1");
// First observation again -> no fire even though it's a completed value.
expect(tracker.observe("t1", "completed")).toBeNull();
});
});

describe("buildAttentionPayload", () => {
it("maps an awareness state + edge into the service-worker payload", () => {
const state = {
environmentId: "env-1",
threadId: "thread-1",
projectTitle: "My Project",
threadTitle: "Fix the bug",
phase: "waiting_for_input",
headline: "Waiting for input",
modelTitle: "claude",
updatedAt: "2026-07-27T00:00:00.000Z",
deepLink: "/threads/env-1/thread-1",
} as unknown as AgentAwarenessState;

expect(buildAttentionPayload(state, "waiting_for_input")).toEqual({
title: "Waiting for input",
body: "My Project · Fix the bug",
key: "env-1::thread-1",
environmentId: "env-1",
threadId: "thread-1",
kind: "waiting_for_input",
});
});

it("keys by environment + thread", () => {
expect(attentionKey("env-1", "thread-1")).toBe("env-1::thread-1");
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@
"@pierre/diffs": "catalog:",
"effect": "catalog:",
"node-pty": "^1.1.0",
"web-push": "^3.6.7",
"yaml": "catalog:"
},
"devDependencies": {
Expand All@@ -42,6 +43,7 @@
"@t3tools/web": "workspace:*",
"@types/bun": "1.3.14",
"@types/node": "catalog:",
"@types/web-push": "^3.6.4",
"effect-acp": "workspace:*",
"effect-codex-app-server": "workspace:*",
"vite-plus": "catalog:"
Expand Down
38 changes: 36 additions & 2 deletions apps/server/src/t3x/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,8 +22,14 @@ import { ServerConfig } from "../config.ts";
import { autoResumeRouteLayer } from "./autoResume/http.ts";
import { AutoResumeReactorLive } from "./autoResume/Reactor.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./autoResume/state.ts";
import { resolveConfig as resolveWebPushConfig } from "./webPush/config.ts";
import { webPushRouteLayer } from "./webPush/http.ts";
import { WebPushReactorLive } from "./webPush/Reactor.ts";
import { makePushSubscriptionStore, PushSubscriptionStore } from "./webPush/state.ts";
import { makeWebPushVapid, WebPushVapid } from "./webPush/vapid.ts";

const AUTO_RESUME_STATE_FILENAME = "t3x-auto-resume.json";
const WEB_PUSH_STATE_FILENAME = "t3x-web-push-subscriptions.json";

/** Wires the durable store to the server state directory. */
const AutoResumeStoreLive = Layer.effect(
Expand All@@ -35,12 +41,37 @@ const AutoResumeStoreLive = Layer.effect(
}),
);

/** Web Push subscription store, wired to the server state directory. */
const PushSubscriptionStoreLive = Layer.effect(
PushSubscriptionStore,
Effect.gen(function* () {
const config = yield* ServerConfig;
const path = yield* Path.Path;
return yield* makePushSubscriptionStore(path.join(config.stateDir, WEB_PUSH_STATE_FILENAME));
}),
);

/** VAPID keypair (env override, else generated + persisted in the secret store). */
const WebPushVapidLive = Layer.effect(WebPushVapid, makeWebPushVapid(resolveWebPushConfig()));

/**
* Shared fork-local deps for the Web Push reactor AND routes. Defined once at module scope so
* both `T3xLayerLive` and `T3xRoutesLive` reference the SAME layer value — Effect memoises
* construction by layer identity across the shared MemoMap, so the reactor and the subscribe
* route mutate one store/keypair rather than racing two copies over a single file (the same
* property `AutoResumeStoreLive` relies on above).
*/
const WebPushDepsLive = Layer.mergeAll(PushSubscriptionStoreLive, WebPushVapidLive);

/**
* The single fork-local layer merged into the server. The auto-resume supervisor
* self-starts on construction; its store is provided here so `server.ts` merges only
* this one layer.
*/
export const T3xLayerLive = AutoResumeReactorLive.pipe(Layer.provide(AutoResumeStoreLive));
export const T3xLayerLive = Layer.mergeAll(
AutoResumeReactorLive.pipe(Layer.provide(AutoResumeStoreLive)),
WebPushReactorLive.pipe(Layer.provide(WebPushDepsLive)),
);

/**
* All fork-local HTTP routes, fanned in here for the same reason as `T3xLayerLive`:
Expand All@@ -66,4 +97,7 @@ export const T3xLayerLive = AutoResumeReactorLive.pipe(Layer.provide(AutoResumeS
* probes rather than importing these two layers, so treat it as a guard on the
* assumption, not proof of this file's graph.
*/
export const T3xRoutesLive = autoResumeRouteLayer.pipe(Layer.provide(AutoResumeStoreLive));
export const T3xRoutesLive = Layer.mergeAll(
autoResumeRouteLayer.pipe(Layer.provide(AutoResumeStoreLive)),
webPushRouteLayer.pipe(Layer.provide(WebPushDepsLive)),
);
145 changes: 145 additions & 0 deletions apps/server/src/t3x/webPush/Reactor.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
/**
* WebPushReactor — closed-tab notification supervisor.
*
* Self-starts one scoped fiber at layer construction (no external `.start()`, so the only
* upstream seam stays the lines already in server.ts). It taps the hot orchestration
* domain-event stream, and for each thread event recomputes the awareness phase from the
* projected shell (the SAME `projectThreadAwareness` the web client uses), edge-detects, and
* on a firing transition sends a Web Push to every registered subscription. Dead
* subscriptions (404/410) are pruned as they surface.
*
* De-dup with the in-page coordinator is the service worker's job: it suppresses when a tab
* is open, so this reactor always sends and the worker only shows when no tab exists.
*
* @module t3x/webPush/Reactor
*/

import type { OrchestrationEvent, OrchestrationProjectShell, ThreadId } from "@t3tools/contracts";
import { projectThreadAwareness } from "@t3tools/shared/agentAwareness";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";

import { ServerEnvironment } from "../../environment/ServerEnvironment.ts";
import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
import { buildAttentionPayload, createAttentionEdgeTracker } from "./attention.ts";
import { resolveConfig } from "./config.ts";
import { sendWebPush } from "./send.ts";
import { PushSubscriptionStore } from "./state.ts";
import { WebPushVapid } from "./vapid.ts";

const SEND_CONCURRENCY = 4;

/** Local mirror of the relay's `eventThreadId`, kept in-seam to avoid coupling to upstream. */
function eventThreadId(event: OrchestrationEvent): ThreadId | null {
const payload = event.payload as { readonly threadId?: unknown };
if (typeof payload.threadId === "string") {
return payload.threadId as ThreadId;
}
if (event.aggregateKind === "thread" && typeof event.aggregateId === "string") {
return event.aggregateId as ThreadId;
}
return null;
}

/**
* Skip events that can never change a thread's attention phase, to avoid a shell fetch +
* awareness recompute on every high-frequency domain event. Conservative denylist: anything
* not listed still flows through (correctness over perf).
*/
function isPhaseRelevantEvent(event: OrchestrationEvent): boolean {
switch (event.type) {
case "thread.meta-updated":
case "thread.runtime-mode-set":
case "thread.interaction-mode-set":
case "thread.proposed-plan-upserted":
return false;
default:
return true;
}
}

const makeSupervisor = Effect.gen(function* () {
const config = resolveConfig();
if (!config.enabled) {
yield* Effect.logInfo("t3x web-push: disabled via T3X_WEB_PUSH_ENABLED");
return;
}

const engine = yield* OrchestrationEngineService;
const snapshotQuery = yield* ProjectionSnapshotQuery;
const serverEnvironment = yield* ServerEnvironment;
const store = yield* PushSubscriptionStore;
const vapid = yield* WebPushVapid;
const tracker = createAttentionEdgeTracker();

const handleThread = (threadId: ThreadId) =>
Effect.gen(function* () {
const threadOpt = yield* snapshotQuery.getThreadShellById(threadId);
if (Option.isNone(threadOpt)) {
tracker.forget(threadId);
return;
}
const thread = threadOpt.value;
const projectOpt = yield* snapshotQuery.getProjectShellById(thread.projectId);
const environmentId = yield* serverEnvironment.getEnvironmentId;

const projectTitle = (
Option.isSome(projectOpt) ? projectOpt.value.title : "T3 Code"
) as OrchestrationProjectShell["title"];

const state = projectThreadAwareness({
environmentId,
project: { title: projectTitle },
thread,
});
// Observe unconditionally so the tracker stays primed even with no subscribers. A
// transition that occurs before the first device subscribes then still fires once a
// subscription exists, instead of being swallowed as a first-seen observation.
const kind = tracker.observe(threadId, state?.phase ?? null);
if (kind === null || state === null) {
return;
}

const subscriptions = yield* store.list;
if (subscriptions.length === 0) {
return;
}

const payload = buildAttentionPayload(state, kind);
yield* Effect.forEach(
subscriptions,
(subscription) =>
sendWebPush(vapid, subscription, payload).pipe(
Effect.flatMap((result) =>
result.expired ? store.removeByEndpoint(subscription.endpoint) : Effect.void,
),
),
{ concurrency: SEND_CONCURRENCY, discard: true },
);
});

yield* Effect.forkScoped(
Stream.runForEach(engine.streamDomainEvents, (event) => {
const threadId = eventThreadId(event);
if (threadId === null || !isPhaseRelevantEvent(event)) {
return Effect.void;
}
return handleThread(threadId).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("t3x web-push: notification handler failed", {
eventType: event.type,
cause: Cause.pretty(cause),
}),
),
);
}),
);

yield* Effect.logInfo("t3x web-push: reactor started");
});

export const WebPushReactorLive = Layer.effectDiscard(makeSupervisor);
113 changes: 113 additions & 0 deletions apps/server/src/t3x/webPush/attention.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
import type { AgentAwarenessState } from "@t3tools/shared/agentAwareness";
import { describe, expect, it } from "vite-plus/test";

import {
attentionKey,
attentionKindForEdge,
buildAttentionPayload,
createAttentionEdgeTracker,
} from "./attention.ts";

describe("attentionKindForEdge", () => {
it("announces waiting phases on entry regardless of the previous phase", () => {
expect(attentionKindForEdge("running", "waiting_for_approval")).toBe("waiting_for_approval");
expect(attentionKindForEdge("completed", "waiting_for_input")).toBe("waiting_for_input");
});

it("announces completed only out of running", () => {
expect(attentionKindForEdge("running", "completed")).toBe("completed");
expect(attentionKindForEdge("starting", "completed")).toBeNull();
expect(attentionKindForEdge(null, "completed")).toBeNull();
});

it("stays silent for non-attention phases", () => {
expect(attentionKindForEdge("waiting_for_input", "running")).toBeNull();
expect(attentionKindForEdge("running", "starting")).toBeNull();
expect(attentionKindForEdge("running", "failed")).toBeNull();
expect(attentionKindForEdge("running", "stale")).toBeNull();
});
});

describe("createAttentionEdgeTracker", () => {
it("never fires on a thread's first observation", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "waiting_for_input")).toBeNull();
});

it("fires completed only on running -> completed", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "running")).toBeNull(); // first-seen
expect(tracker.observe("t1", "completed")).toBe("completed");
});

it("does not fire completed when the thread was never seen running", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "starting")).toBeNull(); // first-seen
expect(tracker.observe("t1", "completed")).toBeNull(); // starting -> completed
});

it("fires waiting_for_input on entry after a prior observation", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "running")).toBeNull();
expect(tracker.observe("t1", "waiting_for_input")).toBe("waiting_for_input");
});

it("does not re-fire while the phase is unchanged", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t1", "waiting_for_approval")).toBe("waiting_for_approval");
expect(tracker.observe("t1", "waiting_for_approval")).toBeNull();
});

it("treats a null phase as an observation that never fires", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t1", null)).toBeNull();
// Coming back to completed from a null observation is not a running->completed edge.
expect(tracker.observe("t1", "completed")).toBeNull();
});

it("tracks threads independently", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t2", "completed")).toBeNull(); // t2 first-seen
expect(tracker.observe("t1", "completed")).toBe("completed");
});

it("re-arms first-seen after forget", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
tracker.forget("t1");
// First observation again -> no fire even though it's a completed value.
expect(tracker.observe("t1", "completed")).toBeNull();
});
});

describe("buildAttentionPayload", () => {
it("maps an awareness state + edge into the service-worker payload", () => {
const state = {
environmentId: "env-1",
threadId: "thread-1",
projectTitle: "My Project",
threadTitle: "Fix the bug",
phase: "waiting_for_input",
headline: "Waiting for input",
modelTitle: "claude",
updatedAt: "2026-07-27T00:00:00.000Z",
deepLink: "/threads/env-1/thread-1",
} as unknown as AgentAwarenessState;

expect(buildAttentionPayload(state, "waiting_for_input")).toEqual({
title: "Waiting for input",
body: "My Project · Fix the bug",
key: "env-1::thread-1",
environmentId: "env-1",
threadId: "thread-1",
kind: "waiting_for_input",
});
});

it("keys by environment + thread", () => {
expect(attentionKey("env-1", "thread-1")).toBe("env-1::thread-1");
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@
"@pierre/diffs": "catalog:",
"effect": "catalog:",
"node-pty": "^1.1.0",
"web-push": "^3.6.7",
"yaml": "catalog:"
},
"devDependencies": {
Expand All@@ -42,6 +43,7 @@
"@t3tools/web": "workspace:*",
"@types/bun": "1.3.14",
"@types/node": "catalog:",
"@types/web-push": "^3.6.4",
"effect-acp": "workspace:*",
"effect-codex-app-server": "workspace:*",
"vite-plus": "catalog:"
Expand Down
38 changes: 36 additions & 2 deletions apps/server/src/t3x/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,8 +22,14 @@ import { ServerConfig } from "../config.ts";
import { autoResumeRouteLayer } from "./autoResume/http.ts";
import { AutoResumeReactorLive } from "./autoResume/Reactor.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./autoResume/state.ts";
import { resolveConfig as resolveWebPushConfig } from "./webPush/config.ts";
import { webPushRouteLayer } from "./webPush/http.ts";
import { WebPushReactorLive } from "./webPush/Reactor.ts";
import { makePushSubscriptionStore, PushSubscriptionStore } from "./webPush/state.ts";
import { makeWebPushVapid, WebPushVapid } from "./webPush/vapid.ts";

const AUTO_RESUME_STATE_FILENAME = "t3x-auto-resume.json";
const WEB_PUSH_STATE_FILENAME = "t3x-web-push-subscriptions.json";

/** Wires the durable store to the server state directory. */
const AutoResumeStoreLive = Layer.effect(
Expand All@@ -35,12 +41,37 @@ const AutoResumeStoreLive = Layer.effect(
}),
);

/** Web Push subscription store, wired to the server state directory. */
const PushSubscriptionStoreLive = Layer.effect(
PushSubscriptionStore,
Effect.gen(function* () {
const config = yield* ServerConfig;
const path = yield* Path.Path;
return yield* makePushSubscriptionStore(path.join(config.stateDir, WEB_PUSH_STATE_FILENAME));
}),
);

/** VAPID keypair (env override, else generated + persisted in the secret store). */
const WebPushVapidLive = Layer.effect(WebPushVapid, makeWebPushVapid(resolveWebPushConfig()));

/**
* Shared fork-local deps for the Web Push reactor AND routes. Defined once at module scope so
* both `T3xLayerLive` and `T3xRoutesLive` reference the SAME layer value — Effect memoises
* construction by layer identity across the shared MemoMap, so the reactor and the subscribe
* route mutate one store/keypair rather than racing two copies over a single file (the same
* property `AutoResumeStoreLive` relies on above).
*/
const WebPushDepsLive = Layer.mergeAll(PushSubscriptionStoreLive, WebPushVapidLive);

/**
* The single fork-local layer merged into the server. The auto-resume supervisor
* self-starts on construction; its store is provided here so `server.ts` merges only
* this one layer.
*/
export const T3xLayerLive = AutoResumeReactorLive.pipe(Layer.provide(AutoResumeStoreLive));
export const T3xLayerLive = Layer.mergeAll(
AutoResumeReactorLive.pipe(Layer.provide(AutoResumeStoreLive)),
WebPushReactorLive.pipe(Layer.provide(WebPushDepsLive)),
);

/**
* All fork-local HTTP routes, fanned in here for the same reason as `T3xLayerLive`:
Expand All@@ -66,4 +97,7 @@ export const T3xLayerLive = AutoResumeReactorLive.pipe(Layer.provide(AutoResumeS
* probes rather than importing these two layers, so treat it as a guard on the
* assumption, not proof of this file's graph.
*/
export const T3xRoutesLive = autoResumeRouteLayer.pipe(Layer.provide(AutoResumeStoreLive));
export const T3xRoutesLive = Layer.mergeAll(
autoResumeRouteLayer.pipe(Layer.provide(AutoResumeStoreLive)),
webPushRouteLayer.pipe(Layer.provide(WebPushDepsLive)),
);
145 changes: 145 additions & 0 deletions apps/server/src/t3x/webPush/Reactor.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
/**
* WebPushReactor — closed-tab notification supervisor.
*
* Self-starts one scoped fiber at layer construction (no external `.start()`, so the only
* upstream seam stays the lines already in server.ts). It taps the hot orchestration
* domain-event stream, and for each thread event recomputes the awareness phase from the
* projected shell (the SAME `projectThreadAwareness` the web client uses), edge-detects, and
* on a firing transition sends a Web Push to every registered subscription. Dead
* subscriptions (404/410) are pruned as they surface.
*
* De-dup with the in-page coordinator is the service worker's job: it suppresses when a tab
* is open, so this reactor always sends and the worker only shows when no tab exists.
*
* @module t3x/webPush/Reactor
*/

import type { OrchestrationEvent, OrchestrationProjectShell, ThreadId } from "@t3tools/contracts";
import { projectThreadAwareness } from "@t3tools/shared/agentAwareness";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";

import { ServerEnvironment } from "../../environment/ServerEnvironment.ts";
import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
import { buildAttentionPayload, createAttentionEdgeTracker } from "./attention.ts";
import { resolveConfig } from "./config.ts";
import { sendWebPush } from "./send.ts";
import { PushSubscriptionStore } from "./state.ts";
import { WebPushVapid } from "./vapid.ts";

const SEND_CONCURRENCY = 4;

/** Local mirror of the relay's `eventThreadId`, kept in-seam to avoid coupling to upstream. */
function eventThreadId(event: OrchestrationEvent): ThreadId | null {
const payload = event.payload as { readonly threadId?: unknown };
if (typeof payload.threadId === "string") {
return payload.threadId as ThreadId;
}
if (event.aggregateKind === "thread" && typeof event.aggregateId === "string") {
return event.aggregateId as ThreadId;
}
return null;
}

/**
* Skip events that can never change a thread's attention phase, to avoid a shell fetch +
* awareness recompute on every high-frequency domain event. Conservative denylist: anything
* not listed still flows through (correctness over perf).
*/
function isPhaseRelevantEvent(event: OrchestrationEvent): boolean {
switch (event.type) {
case "thread.meta-updated":
case "thread.runtime-mode-set":
case "thread.interaction-mode-set":
case "thread.proposed-plan-upserted":
return false;
default:
return true;
}
}

const makeSupervisor = Effect.gen(function* () {
const config = resolveConfig();
if (!config.enabled) {
yield* Effect.logInfo("t3x web-push: disabled via T3X_WEB_PUSH_ENABLED");
return;
}

const engine = yield* OrchestrationEngineService;
const snapshotQuery = yield* ProjectionSnapshotQuery;
const serverEnvironment = yield* ServerEnvironment;
const store = yield* PushSubscriptionStore;
const vapid = yield* WebPushVapid;
const tracker = createAttentionEdgeTracker();

const handleThread = (threadId: ThreadId) =>
Effect.gen(function* () {
const threadOpt = yield* snapshotQuery.getThreadShellById(threadId);
if (Option.isNone(threadOpt)) {
tracker.forget(threadId);
return;
}
const thread = threadOpt.value;
const projectOpt = yield* snapshotQuery.getProjectShellById(thread.projectId);
const environmentId = yield* serverEnvironment.getEnvironmentId;

const projectTitle = (
Option.isSome(projectOpt) ? projectOpt.value.title : "T3 Code"
) as OrchestrationProjectShell["title"];

const state = projectThreadAwareness({
environmentId,
project: { title: projectTitle },
thread,
});
// Observe unconditionally so the tracker stays primed even with no subscribers. A
// transition that occurs before the first device subscribes then still fires once a
// subscription exists, instead of being swallowed as a first-seen observation.
const kind = tracker.observe(threadId, state?.phase ?? null);
if (kind === null || state === null) {
return;
}

const subscriptions = yield* store.list;
if (subscriptions.length === 0) {
return;
}

const payload = buildAttentionPayload(state, kind);
yield* Effect.forEach(
subscriptions,
(subscription) =>
sendWebPush(vapid, subscription, payload).pipe(
Effect.flatMap((result) =>
result.expired ? store.removeByEndpoint(subscription.endpoint) : Effect.void,
),
),
{ concurrency: SEND_CONCURRENCY, discard: true },
);
});

yield* Effect.forkScoped(
Stream.runForEach(engine.streamDomainEvents, (event) => {
const threadId = eventThreadId(event);
if (threadId === null || !isPhaseRelevantEvent(event)) {
return Effect.void;
}
return handleThread(threadId).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("t3x web-push: notification handler failed", {
eventType: event.type,
cause: Cause.pretty(cause),
}),
),
);
}),
);

yield* Effect.logInfo("t3x web-push: reactor started");
});

export const WebPushReactorLive = Layer.effectDiscard(makeSupervisor);
113 changes: 113 additions & 0 deletions apps/server/src/t3x/webPush/attention.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
import type { AgentAwarenessState } from "@t3tools/shared/agentAwareness";
import { describe, expect, it } from "vite-plus/test";

import {
attentionKey,
attentionKindForEdge,
buildAttentionPayload,
createAttentionEdgeTracker,
} from "./attention.ts";

describe("attentionKindForEdge", () => {
it("announces waiting phases on entry regardless of the previous phase", () => {
expect(attentionKindForEdge("running", "waiting_for_approval")).toBe("waiting_for_approval");
expect(attentionKindForEdge("completed", "waiting_for_input")).toBe("waiting_for_input");
});

it("announces completed only out of running", () => {
expect(attentionKindForEdge("running", "completed")).toBe("completed");
expect(attentionKindForEdge("starting", "completed")).toBeNull();
expect(attentionKindForEdge(null, "completed")).toBeNull();
});

it("stays silent for non-attention phases", () => {
expect(attentionKindForEdge("waiting_for_input", "running")).toBeNull();
expect(attentionKindForEdge("running", "starting")).toBeNull();
expect(attentionKindForEdge("running", "failed")).toBeNull();
expect(attentionKindForEdge("running", "stale")).toBeNull();
});
});

describe("createAttentionEdgeTracker", () => {
it("never fires on a thread's first observation", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "waiting_for_input")).toBeNull();
});

it("fires completed only on running -> completed", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "running")).toBeNull(); // first-seen
expect(tracker.observe("t1", "completed")).toBe("completed");
});

it("does not fire completed when the thread was never seen running", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "starting")).toBeNull(); // first-seen
expect(tracker.observe("t1", "completed")).toBeNull(); // starting -> completed
});

it("fires waiting_for_input on entry after a prior observation", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "running")).toBeNull();
expect(tracker.observe("t1", "waiting_for_input")).toBe("waiting_for_input");
});

it("does not re-fire while the phase is unchanged", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t1", "waiting_for_approval")).toBe("waiting_for_approval");
expect(tracker.observe("t1", "waiting_for_approval")).toBeNull();
});

it("treats a null phase as an observation that never fires", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t1", null)).toBeNull();
// Coming back to completed from a null observation is not a running->completed edge.
expect(tracker.observe("t1", "completed")).toBeNull();
});

it("tracks threads independently", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t2", "completed")).toBeNull(); // t2 first-seen
expect(tracker.observe("t1", "completed")).toBe("completed");
});

it("re-arms first-seen after forget", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
tracker.forget("t1");
// First observation again -> no fire even though it's a completed value.
expect(tracker.observe("t1", "completed")).toBeNull();
});
});

describe("buildAttentionPayload", () => {
it("maps an awareness state + edge into the service-worker payload", () => {
const state = {
environmentId: "env-1",
threadId: "thread-1",
projectTitle: "My Project",
threadTitle: "Fix the bug",
phase: "waiting_for_input",
headline: "Waiting for input",
modelTitle: "claude",
updatedAt: "2026-07-27T00:00:00.000Z",
deepLink: "/threads/env-1/thread-1",
} as unknown as AgentAwarenessState;

expect(buildAttentionPayload(state, "waiting_for_input")).toEqual({
title: "Waiting for input",
body: "My Project · Fix the bug",
key: "env-1::thread-1",
environmentId: "env-1",
threadId: "thread-1",
kind: "waiting_for_input",
});
});

it("keys by environment + thread", () => {
expect(attentionKey("env-1", "thread-1")).toBe("env-1::thread-1");
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@
"@pierre/diffs": "catalog:",
"effect": "catalog:",
"node-pty": "^1.1.0",
"web-push": "^3.6.7",
"yaml": "catalog:"
},
"devDependencies": {
Expand All@@ -42,6 +43,7 @@
"@t3tools/web": "workspace:*",
"@types/bun": "1.3.14",
"@types/node": "catalog:",
"@types/web-push": "^3.6.4",
"effect-acp": "workspace:*",
"effect-codex-app-server": "workspace:*",
"vite-plus": "catalog:"
Expand Down
38 changes: 36 additions & 2 deletions apps/server/src/t3x/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,8 +22,14 @@ import { ServerConfig } from "../config.ts";
import { autoResumeRouteLayer } from "./autoResume/http.ts";
import { AutoResumeReactorLive } from "./autoResume/Reactor.ts";
import { AutoResumeStore, makeAutoResumeStore } from "./autoResume/state.ts";
import { resolveConfig as resolveWebPushConfig } from "./webPush/config.ts";
import { webPushRouteLayer } from "./webPush/http.ts";
import { WebPushReactorLive } from "./webPush/Reactor.ts";
import { makePushSubscriptionStore, PushSubscriptionStore } from "./webPush/state.ts";
import { makeWebPushVapid, WebPushVapid } from "./webPush/vapid.ts";

const AUTO_RESUME_STATE_FILENAME = "t3x-auto-resume.json";
const WEB_PUSH_STATE_FILENAME = "t3x-web-push-subscriptions.json";

/** Wires the durable store to the server state directory. */
const AutoResumeStoreLive = Layer.effect(
Expand All@@ -35,12 +41,37 @@ const AutoResumeStoreLive = Layer.effect(
}),
);

/** Web Push subscription store, wired to the server state directory. */
const PushSubscriptionStoreLive = Layer.effect(
PushSubscriptionStore,
Effect.gen(function* () {
const config = yield* ServerConfig;
const path = yield* Path.Path;
return yield* makePushSubscriptionStore(path.join(config.stateDir, WEB_PUSH_STATE_FILENAME));
}),
);

/** VAPID keypair (env override, else generated + persisted in the secret store). */
const WebPushVapidLive = Layer.effect(WebPushVapid, makeWebPushVapid(resolveWebPushConfig()));

/**
* Shared fork-local deps for the Web Push reactor AND routes. Defined once at module scope so
* both `T3xLayerLive` and `T3xRoutesLive` reference the SAME layer value — Effect memoises
* construction by layer identity across the shared MemoMap, so the reactor and the subscribe
* route mutate one store/keypair rather than racing two copies over a single file (the same
* property `AutoResumeStoreLive` relies on above).
*/
const WebPushDepsLive = Layer.mergeAll(PushSubscriptionStoreLive, WebPushVapidLive);

/**
* The single fork-local layer merged into the server. The auto-resume supervisor
* self-starts on construction; its store is provided here so `server.ts` merges only
* this one layer.
*/
export const T3xLayerLive = AutoResumeReactorLive.pipe(Layer.provide(AutoResumeStoreLive));
export const T3xLayerLive = Layer.mergeAll(
AutoResumeReactorLive.pipe(Layer.provide(AutoResumeStoreLive)),
WebPushReactorLive.pipe(Layer.provide(WebPushDepsLive)),
);

/**
* All fork-local HTTP routes, fanned in here for the same reason as `T3xLayerLive`:
Expand All@@ -66,4 +97,7 @@ export const T3xLayerLive = AutoResumeReactorLive.pipe(Layer.provide(AutoResumeS
* probes rather than importing these two layers, so treat it as a guard on the
* assumption, not proof of this file's graph.
*/
export const T3xRoutesLive = autoResumeRouteLayer.pipe(Layer.provide(AutoResumeStoreLive));
export const T3xRoutesLive = Layer.mergeAll(
autoResumeRouteLayer.pipe(Layer.provide(AutoResumeStoreLive)),
webPushRouteLayer.pipe(Layer.provide(WebPushDepsLive)),
);
145 changes: 145 additions & 0 deletions apps/server/src/t3x/webPush/Reactor.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
/**
* WebPushReactor — closed-tab notification supervisor.
*
* Self-starts one scoped fiber at layer construction (no external `.start()`, so the only
* upstream seam stays the lines already in server.ts). It taps the hot orchestration
* domain-event stream, and for each thread event recomputes the awareness phase from the
* projected shell (the SAME `projectThreadAwareness` the web client uses), edge-detects, and
* on a firing transition sends a Web Push to every registered subscription. Dead
* subscriptions (404/410) are pruned as they surface.
*
* De-dup with the in-page coordinator is the service worker's job: it suppresses when a tab
* is open, so this reactor always sends and the worker only shows when no tab exists.
*
* @module t3x/webPush/Reactor
*/

import type { OrchestrationEvent, OrchestrationProjectShell, ThreadId } from "@t3tools/contracts";
import { projectThreadAwareness } from "@t3tools/shared/agentAwareness";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";

import { ServerEnvironment } from "../../environment/ServerEnvironment.ts";
import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
import { buildAttentionPayload, createAttentionEdgeTracker } from "./attention.ts";
import { resolveConfig } from "./config.ts";
import { sendWebPush } from "./send.ts";
import { PushSubscriptionStore } from "./state.ts";
import { WebPushVapid } from "./vapid.ts";

const SEND_CONCURRENCY = 4;

/** Local mirror of the relay's `eventThreadId`, kept in-seam to avoid coupling to upstream. */
function eventThreadId(event: OrchestrationEvent): ThreadId | null {
const payload = event.payload as { readonly threadId?: unknown };
if (typeof payload.threadId === "string") {
return payload.threadId as ThreadId;
}
if (event.aggregateKind === "thread" && typeof event.aggregateId === "string") {
return event.aggregateId as ThreadId;
}
return null;
}

/**
* Skip events that can never change a thread's attention phase, to avoid a shell fetch +
* awareness recompute on every high-frequency domain event. Conservative denylist: anything
* not listed still flows through (correctness over perf).
*/
function isPhaseRelevantEvent(event: OrchestrationEvent): boolean {
switch (event.type) {
case "thread.meta-updated":
case "thread.runtime-mode-set":
case "thread.interaction-mode-set":
case "thread.proposed-plan-upserted":
return false;
default:
return true;
}
}

const makeSupervisor = Effect.gen(function* () {
const config = resolveConfig();
if (!config.enabled) {
yield* Effect.logInfo("t3x web-push: disabled via T3X_WEB_PUSH_ENABLED");
return;
}

const engine = yield* OrchestrationEngineService;
const snapshotQuery = yield* ProjectionSnapshotQuery;
const serverEnvironment = yield* ServerEnvironment;
const store = yield* PushSubscriptionStore;
const vapid = yield* WebPushVapid;
const tracker = createAttentionEdgeTracker();

const handleThread = (threadId: ThreadId) =>
Effect.gen(function* () {
const threadOpt = yield* snapshotQuery.getThreadShellById(threadId);
if (Option.isNone(threadOpt)) {
tracker.forget(threadId);
return;
}
const thread = threadOpt.value;
const projectOpt = yield* snapshotQuery.getProjectShellById(thread.projectId);
const environmentId = yield* serverEnvironment.getEnvironmentId;

const projectTitle = (
Option.isSome(projectOpt) ? projectOpt.value.title : "T3 Code"
) as OrchestrationProjectShell["title"];

const state = projectThreadAwareness({
environmentId,
project: { title: projectTitle },
thread,
});
// Observe unconditionally so the tracker stays primed even with no subscribers. A
// transition that occurs before the first device subscribes then still fires once a
// subscription exists, instead of being swallowed as a first-seen observation.
const kind = tracker.observe(threadId, state?.phase ?? null);
if (kind === null || state === null) {
return;
}

const subscriptions = yield* store.list;
if (subscriptions.length === 0) {
return;
}

const payload = buildAttentionPayload(state, kind);
yield* Effect.forEach(
subscriptions,
(subscription) =>
sendWebPush(vapid, subscription, payload).pipe(
Effect.flatMap((result) =>
result.expired ? store.removeByEndpoint(subscription.endpoint) : Effect.void,
),
),
{ concurrency: SEND_CONCURRENCY, discard: true },
);
});

yield* Effect.forkScoped(
Stream.runForEach(engine.streamDomainEvents, (event) => {
const threadId = eventThreadId(event);
if (threadId === null || !isPhaseRelevantEvent(event)) {
return Effect.void;
}
return handleThread(threadId).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("t3x web-push: notification handler failed", {
eventType: event.type,
cause: Cause.pretty(cause),
}),
),
);
}),
);

yield* Effect.logInfo("t3x web-push: reactor started");
});

export const WebPushReactorLive = Layer.effectDiscard(makeSupervisor);
113 changes: 113 additions & 0 deletions apps/server/src/t3x/webPush/attention.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
import type { AgentAwarenessState } from "@t3tools/shared/agentAwareness";
import { describe, expect, it } from "vite-plus/test";

import {
attentionKey,
attentionKindForEdge,
buildAttentionPayload,
createAttentionEdgeTracker,
} from "./attention.ts";

describe("attentionKindForEdge", () => {
it("announces waiting phases on entry regardless of the previous phase", () => {
expect(attentionKindForEdge("running", "waiting_for_approval")).toBe("waiting_for_approval");
expect(attentionKindForEdge("completed", "waiting_for_input")).toBe("waiting_for_input");
});

it("announces completed only out of running", () => {
expect(attentionKindForEdge("running", "completed")).toBe("completed");
expect(attentionKindForEdge("starting", "completed")).toBeNull();
expect(attentionKindForEdge(null, "completed")).toBeNull();
});

it("stays silent for non-attention phases", () => {
expect(attentionKindForEdge("waiting_for_input", "running")).toBeNull();
expect(attentionKindForEdge("running", "starting")).toBeNull();
expect(attentionKindForEdge("running", "failed")).toBeNull();
expect(attentionKindForEdge("running", "stale")).toBeNull();
});
});

describe("createAttentionEdgeTracker", () => {
it("never fires on a thread's first observation", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "waiting_for_input")).toBeNull();
});

it("fires completed only on running -> completed", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "running")).toBeNull(); // first-seen
expect(tracker.observe("t1", "completed")).toBe("completed");
});

it("does not fire completed when the thread was never seen running", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "starting")).toBeNull(); // first-seen
expect(tracker.observe("t1", "completed")).toBeNull(); // starting -> completed
});

it("fires waiting_for_input on entry after a prior observation", () => {
const tracker = createAttentionEdgeTracker();
expect(tracker.observe("t1", "running")).toBeNull();
expect(tracker.observe("t1", "waiting_for_input")).toBe("waiting_for_input");
});

it("does not re-fire while the phase is unchanged", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t1", "waiting_for_approval")).toBe("waiting_for_approval");
expect(tracker.observe("t1", "waiting_for_approval")).toBeNull();
});

it("treats a null phase as an observation that never fires", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t1", null)).toBeNull();
// Coming back to completed from a null observation is not a running->completed edge.
expect(tracker.observe("t1", "completed")).toBeNull();
});

it("tracks threads independently", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
expect(tracker.observe("t2", "completed")).toBeNull(); // t2 first-seen
expect(tracker.observe("t1", "completed")).toBe("completed");
});

it("re-arms first-seen after forget", () => {
const tracker = createAttentionEdgeTracker();
tracker.observe("t1", "running");
tracker.forget("t1");
// First observation again -> no fire even though it's a completed value.
expect(tracker.observe("t1", "completed")).toBeNull();
});
});

describe("buildAttentionPayload", () => {
it("maps an awareness state + edge into the service-worker payload", () => {
const state = {
environmentId: "env-1",
threadId: "thread-1",
projectTitle: "My Project",
threadTitle: "Fix the bug",
phase: "waiting_for_input",
headline: "Waiting for input",
modelTitle: "claude",
updatedAt: "2026-07-27T00:00:00.000Z",
deepLink: "/threads/env-1/thread-1",
} as unknown as AgentAwarenessState;

expect(buildAttentionPayload(state, "waiting_for_input")).toEqual({
title: "Waiting for input",
body: "My Project · Fix the bug",
key: "env-1::thread-1",
environmentId: "env-1",
threadId: "thread-1",
kind: "waiting_for_input",
});
});

it("keys by environment + thread", () => {
expect(attentionKey("env-1", "thread-1")).toBe("env-1::thread-1");
});
});
Loading
Loading