Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1aef1d6
activity(p0): iOS contract decoder hardening — unknown-tolerant enums…
arul28 Aug 1, 2026
21d53ab
activity(p2): shared foundation — SessionStatusLabel extraction, acti…
arul28 Aug 1, 2026
8dbbc85
activity(p0): relay bug-fix core — alert/content fingerprint split, d…
arul28 Aug 1, 2026
713b3f8
activity(p3): desktop popover — ActivityCard + HeaderActivityControl,…
arul28 Aug 1, 2026
3de2aac
activity(p1b): coordinator ack fencing + machine-mute IPC — putMachin…
arul28 Aug 1, 2026
c992270
activity(p0): relay alert pushes carry content-available for iOS back…
arul28 Aug 1, 2026
d28e514
activity(p1): publisher protocol 2 — fingerprint split, delta/reconci…
arul28 Aug 1, 2026
c86caac
activity(p6a): iOS services — 20s foreground poll, push-triggered ref…
arul28 Aug 1, 2026
85122f5
activity(p4): expanded pane + settings + route — split Sessions/Inbox…
arul28 Aug 1, 2026
71cbf4f
activity(p6b): iOS drawer + hub — two-bucket ActivityDrawerSheet with…
arul28 Aug 1, 2026
945195a
activity(p5): notch + menu bar rework — event toasts with per-kind tr…
arul28 Aug 1, 2026
6bfacea
activity(p7): iOS parity + widget — headerless singleton lanes, deskt…
arul28 Aug 1, 2026
55641f4
activity(p8): rename sweep — components/activity move, store/hook/TUI…
arul28 Aug 1, 2026
abf949b
activity(p8): fix notch test expectation for renamed sign-in copy
arul28 Aug 1, 2026
3024b80
activity(quality): Swift fixes — desktop-parity tier fallback, bounde…
arul28 Aug 1, 2026
06914e9
activity(quality): platform fixes — statusSince re-alert discriminato…
arul28 Aug 1, 2026
42efbe6
activity(quality): desktop fixes — legacy pref tolerance, machines-ma…
arul28 Aug 1, 2026
c8b7d04
activity(quality): re-review fixes — phase-anchored roster statusSinc…
arul28 Aug 1, 2026
a6900c8
activity(test): parity passes — registry machine-mute action, TUI idl…
arul28 Aug 1, 2026
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
11 changes: 9 additions & 2 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ The runtime exposes two layers of JSON-RPC methods (`src/multiProjectRpcServer.t
```text
ade/initialize ade/initialized ping shutdown exit
runtime/info machineInfo.get
account.call
account.call attention.call
projects.list projects.add projects.remove projects.touch
projects.browseDirectories projects.getDetail
projects.getWorkSummary projects.getDefaultParentDir
Expand All @@ -208,6 +208,13 @@ directory operations. Prefer the typed `ade login`, `ade auth status`,
commands; they select the CTO role where credential-bearing operations require
it and keep account-machine pairing on the DPoP-bound runtime path.

`attention.call` is the CTO-gated account-wide Activity surface backing `ade
code`'s `/activity` pane (`getSnapshot`, `getMachineSnapshot`, `acknowledge`,
`reportPresence`, `getPreferences`, `putPreferences`). `attention` stays as the
frozen wire identifier for the method, the action domain, and the item ids even
though the product surface is now called Activity. Agents on a desktop endpoint
reach the same operations through `ade actions run attention.<action>`.

`runtimeEvents.subscribe` returns `eventEpoch`, `nextCursor`, `hasMore`, `gap`, and `oldestCursor`; when `gap` is true, the caller's cursor predates the retained buffer and it should refresh state before resuming from `oldestCursor` / `nextCursor`.

`personalChats.call` dispatches the machine action registry advertised as
Expand Down Expand Up @@ -489,7 +496,7 @@ ade storage compress --text # losslessly compress old c
ade --role cto storage maintenance --text # run the policy-driven ledger maintenance sweep now (CTO)
ade storage actions --text # raw storage service actions (cleanupPreview/cleanup live here)
ade actions list --domain chat --text
ade --role cto actions list --domain attention --text # discover account-wide Attention actions
ade --role cto actions list --domain attention --text # discover account-wide Activity actions (domain name is a frozen wire identifier)
ade --role cto actions run attention.getSnapshot --input-json '{"since":0}' --json
ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts
ade actions run pty.resumeSession --arg sessionId=session-id
Expand Down
2 changes: 1 addition & 1 deletion apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3465,7 +3465,7 @@ describe("adeRpcServer", () => {
(entry: { name: string }) => entry.name === "attention.getSnapshot",
);
expect(getSnapshotAction).toMatchObject({
description: expect.stringContaining("account-wide Attention stream"),
description: expect.stringContaining("account-wide Activity stream"),
input: expect.stringContaining("streamId"),
example: expect.stringContaining("attention.getSnapshot"),
});
Expand Down
7 changes: 6 additions & 1 deletion apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ import type { BuiltInBrowserDesktopBridgeClient } from "./services/builtInBrowse
import { resolveMachineAdeLayout } from "./services/projects/machineLayout";
import { createPushRegistrationStore } from "./services/push/pushRegistrationStore";
import { createPushRelayClient } from "./services/push/pushRelayClient";
import { getSharedPushPublisherService, resolvePushRelayStateFile, type PushPrNotification, type PushPublisherService } from "./services/push/pushPublisherService";
import { getSharedPushPublisherService, resolvePushRelayStateFile, type PushPrNotification, type PushPublisherDeps, type PushPublisherService } from "./services/push/pushPublisherService";
import type { createFileService } from "../../desktop/src/main/services/files/fileService";
import type { AppNavigationRequest, AppNavigationResult, PortLease } from "../../desktop/src/shared/types";
import type { PrEventPayload } from "../../desktop/src/shared/types/prs";
Expand Down Expand Up @@ -235,6 +235,7 @@ export type AdeRuntimeSyncOptions = {
phonePairingStateDir?: string;
projectCatalogProvider?: Parameters<typeof createSyncService>[0]["projectCatalogProvider"];
rosterProvider?: Parameters<typeof createSyncService>[0]["rosterProvider"];
activityRosterProvider?: PushPublisherDeps["activityRosterProvider"];
foreignChatProvider?: Parameters<typeof createSyncService>[0]["foreignChatProvider"];
personalChatScope?: Parameters<typeof createSyncService>[0]["personalChatScope"];
remoteCommandExecutor?: Parameters<typeof createSyncService>[0]["remoteCommandExecutor"];
Expand Down Expand Up @@ -1530,8 +1531,12 @@ export async function createAdeRuntime(args: {
}
return { machineKey, deviceId };
},
activityRosterProvider: resolvedArgs.syncRuntime?.activityRosterProvider,
};
});
pushPublisherService.setActivityRosterProvider(
resolvedArgs.syncRuntime?.activityRosterProvider ?? null,
);
const detachPushSources = publishPushEvents
? pushPublisherService.attachSources(projectId, {
// The lightweight no-agent headless chat stub intentionally exposes
Expand Down
24 changes: 14 additions & 10 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2306,7 +2306,7 @@ const HELP_BY_COMMAND: Record<string, string> = {
$ ade actions list --text Domain-grouped action catalog
$ ade actions list --domain git --text Narrow the catalog
$ ade --role cto actions list --domain attention --text
Discover account-wide Attention actions
Discover account-wide Activity actions
$ ade --role cto actions run attention.getSnapshot --input-json '{"since":0}' --json
Read work across connected machines and projects
$ ade actions run <domain.action> --input-json '{"key":"value"}'
Expand Down Expand Up @@ -15989,6 +15989,17 @@ async function runServe(
personalChatScope,
}),
);
// Shared by mobile roster delivery and protocol-2 Activity publishing. The
// closure is evaluated only after `scopeRegistry` has been assigned.
const activityRosterProvider = {
buildSnapshot: () =>
buildRosterSnapshot({
projectRegistry,
scopeRegistry,
hostProjectId: preferredSyncProjectId,
logger: headlessProjectLogger,
}),
};
scopeRegistry = new ProjectScopeRegistry(projectRegistry, {
syncRuntime: {
enabled: syncEnabled,
Expand All @@ -16008,15 +16019,8 @@ async function runServe(
// which is assigned by this very `new ProjectScopeRegistry(...)` call —
// safe because `buildSnapshot` only runs later (on `roster_subscribe`),
// by which point the binding is set (mirrors machineProjectCatalogProvider).
rosterProvider: {
buildSnapshot: () =>
buildRosterSnapshot({
projectRegistry,
scopeRegistry,
hostProjectId: preferredSyncProjectId,
logger: headlessProjectLogger,
}),
},
rosterProvider: activityRosterProvider,
activityRosterProvider,
// Cross-project chat "quick look": lets the phone stream a foreign
// project's chat transcript read-only without a project switch. Reads
// straight off that project's `.ade` transcripts dir (registry-validated,
Expand Down
8 changes: 4 additions & 4 deletions apps/ade-cli/src/multiProjectRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1261,7 +1261,7 @@ export function createMultiProjectRpcRequestHandler(
if (!publisher) {
throw new JsonRpcError(
JsonRpcErrorCode.invalidRequest,
"Account Attention is unavailable until the ADE brain is ready.",
"Account Activity is unavailable until the ADE brain is ready.",
);
}
if (action === "getSnapshot") {
Expand Down Expand Up @@ -1349,7 +1349,7 @@ export function createMultiProjectRpcRequestHandler(
if (!accountOwnerId || currentAccountOwnerUserId() !== accountOwnerId) {
throw new JsonRpcError(
JsonRpcErrorCode.invalidRequest,
"The ADE account changed before Attention preferences could be read.",
"The ADE account changed before Activity preferences could be read.",
);
}
return await publisher.getAttentionPreferences(accountOwnerId);
Expand All @@ -1364,7 +1364,7 @@ export function createMultiProjectRpcRequestHandler(
) {
throw new JsonRpcError(
JsonRpcErrorCode.invalidRequest,
"The ADE account changed before Attention preferences could be saved.",
"The ADE account changed before Activity preferences could be saved.",
);
}
await publisher.putAttentionPreferences(
Expand All @@ -1375,7 +1375,7 @@ export function createMultiProjectRpcRequestHandler(
}
throw new JsonRpcError(
JsonRpcErrorCode.methodNotFound,
`Unknown Attention action: ${action || "(empty)"}`,
`Unknown Activity action: ${action || "(empty)"}`,
);
}

Expand Down
107 changes: 107 additions & 0 deletions apps/ade-cli/src/services/push/activityFingerprint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import {
ATTENTION_CONTRACT_VERSION,
type AttentionItem,
} from "../../../../desktop/src/shared/types/attention";
import {
activityAlertFingerprint,
activityContentFingerprint,
} from "./activityFingerprint";

function item(overrides: Partial<AttentionItem> = {}): AttentionItem {
return {
contractVersion: ATTENTION_CONTRACT_VERSION,
id: "agent:machine:session",
revision: 1,
fingerprint: "legacy",
activityTier: "ambient",
kind: "agent",
eventKind: "agent_running",
phase: "running",
machine: {
machineKey: "machine",
name: "MacBook",
online: true,
lastSeenAt: "2026-08-01T12:00:00.000Z",
},
project: { projectId: "project", name: "ADE" },
laneId: "lane",
laneName: "feature",
provider: "Codex",
model: "gpt-5",
title: "Codex is working",
preview: "Working for 1.2s · 120 tokens · 3 files",
privacyPreview: "An ADE agent is working.",
destination: { kind: "session", sessionId: "session", itemId: "approval-1" },
actions: [{ id: "open", kind: "open", label: "Open" }],
occurredAt: "2026-08-01T12:00:00.000Z",
updatedAt: "2026-08-01T12:00:00.000Z",
statusSince: "2026-08-01T12:00:00.000Z",
seenAt: null,
dismissedAt: null,
expiresAt: "2026-08-01T14:00:00.000Z",
...overrides,
};
}

describe("Activity fingerprints", () => {
it("keeps content and alert identity stable across elapsed/count preview churn", () => {
const first = item();
const churned = item({
revision: 99,
preview: "Working for 48.9s · 8,420 tokens · 27 files",
occurredAt: "2026-08-01T12:00:48.000Z",
updatedAt: "2026-08-01T12:00:48.000Z",
expiresAt: "2026-08-01T14:00:48.000Z",
machine: {
...first.machine,
online: false,
lastSeenAt: "2026-08-01T12:00:48.000Z",
},
detail: "different noisy detail",
recentActivity: ["tool event"],
});

expect(activityContentFingerprint(churned)).toBe(activityContentFingerprint(first));
expect(activityAlertFingerprint(churned)).toBe(activityAlertFingerprint(first));
});

it("changes alert identity when the session destination item changes", () => {
const first = item();
const nextApproval = item({
destination: { kind: "session", sessionId: "session", itemId: "approval-2" },
});

expect(activityAlertFingerprint(nextApproval)).not.toBe(activityAlertFingerprint(first));
});

it("changes alert identity when a session or pull request re-enters a phase", () => {
const firstQuestion = item({
eventKind: "agent_needs_you",
phase: "needs_you",
statusSince: "2026-08-01T12:00:00.000Z",
destination: { kind: "session", sessionId: "session", itemId: null },
});
const reenteredQuestion = item({
...firstQuestion,
statusSince: "2026-08-01T12:05:00.000Z",
});
const firstReview = item({
id: "pull-request:machine:42",
kind: "pull_request",
eventKind: "pr_review_requested",
phase: "review_requested",
statusSince: "2026-08-01T12:00:00.000Z",
destination: { kind: "pull_request", number: 42, tab: "activity" },
});
const reenteredReview = item({
...firstReview,
statusSince: "2026-08-01T12:05:00.000Z",
});

expect(activityAlertFingerprint(reenteredQuestion))
.not.toBe(activityAlertFingerprint(firstQuestion));
expect(activityAlertFingerprint(reenteredReview))
.not.toBe(activityAlertFingerprint(firstReview));
});
});
88 changes: 88 additions & 0 deletions apps/ade-cli/src/services/push/activityFingerprint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { createHash } from "node:crypto";
import {
sanitizeAttentionPreview,
type AttentionItem,
} from "../../../../desktop/src/shared/types/attention";

function sha256(value: unknown): string {
return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex");
}

/**
* Remove high-frequency progress copy that does not change what an Activity
* row means. The sanitized preview remains part of the content identity; only
* elapsed durations and token/file counters are normalized away.
*/
export function normalizeActivityPreview(value: string): string {
return sanitizeAttentionPreview(value)
.replace(/\b\d+(?:\.\d+)?\s?(?:ms|s|m|h)\b/gi, "")
.replace(/\b\d[\d,]*(?:\.\d+)?(?=\s+(?:tokens?|files?)\b)/gi, "#")
.replace(/\s+/g, " ")
.trim();
}

/** What the Activity row looks like, excluding timestamps and ack state. */
export function activityContentFingerprint(item: AttentionItem): string {
return sha256({
id: item.id,
kind: item.kind,
eventKind: item.eventKind,
phase: item.phase,
activityTier: item.activityTier ?? null,
laneId: item.laneId ?? null,
laneName: item.laneName ?? null,
provider: item.provider ?? null,
model: item.model ?? null,
title: item.title,
projectId: item.project.projectId,
projectName: item.project.name,
destination: item.destination,
actions: item.actions.map((action) => `${action.id}:${action.kind}`),
planProgress: item.planProgress ?? null,
normalizedPreview: normalizeActivityPreview(item.preview),
});
}

/** Stable identity of one phase entry, independent of preview-copy churn. */
export function activityAlertFingerprint(item: AttentionItem): string {
if (item.kind === "pull_request" && item.destination.kind === "pull_request") {
return sha256({
id: item.id,
eventKind: item.eventKind,
phase: item.phase,
statusSince: item.statusSince ?? null,
number: item.destination.number,
});
}
return sha256({
id: item.id,
eventKind: item.eventKind,
phase: item.phase,
statusSince: item.statusSince ?? null,
itemId: item.destination.kind === "session"
? item.destination.itemId ?? ""
: "",
});
}

/** Cheap publisher-side change detector, including acknowledgment changes. */
export function activityPublishFingerprint(item: AttentionItem): string {
return [
item.contentFingerprint ?? activityContentFingerprint(item),
item.alertFingerprint ?? activityAlertFingerprint(item),
item.seenAt ?? "",
item.dismissedAt ?? "",
].join("\u0000");
}

/** Populate the split fingerprints while preserving the legacy field. */
export function withActivityFingerprints(item: AttentionItem): AttentionItem {
const contentFingerprint = activityContentFingerprint(item);
const alertFingerprint = activityAlertFingerprint(item);
return {
...item,
fingerprint: contentFingerprint,
contentFingerprint,
alertFingerprint,
};
}
Loading
Loading