Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
28ed729
feat(daemon): persist team-collab comment anchor state
lefarcen Jul 6, 2026
f15fbd9
feat(web): comment drift-ladder anchor resolution engine
lefarcen Jul 6, 2026
1dc6ef0
feat(daemon): anchor write-back route for the comment drift ladder
lefarcen Jul 6, 2026
ac007cb
feat(web): render comment drift states in the preview overlay
lefarcen Jul 6, 2026
7f5713f
feat(daemon): collab publish scheduler (author-side sync trigger)
lefarcen Jul 6, 2026
f5d91dc
feat(daemon): collab presence tracker (team presence overlay)
lefarcen Jul 6, 2026
8bfb0fe
feat(daemon): wire collab presence into the daemon (live capability)
lefarcen Jul 6, 2026
8ae1381
feat(daemon): collab sync trigger routes (client-driven publish)
lefarcen Jul 6, 2026
34599ba
feat(web): collab client integration (presence heartbeat + sync polling)
lefarcen Jul 6, 2026
1025a49
feat(cli): od collab subcommand (dual-track for presence + sync)
lefarcen Jul 6, 2026
d809e9b
feat(web): presence overlay UI slice (useCollab hook + PresenceBar)
lefarcen Jul 6, 2026
4f9b7a6
feat(web): persist lost comment anchors (durable ghost pins)
lefarcen Jul 6, 2026
b9f9fcc
refactor(contracts): lift collab presence/sync DTOs into contracts
lefarcen Jul 6, 2026
9e27836
feat(web): collab demo surface at /collab-demo (visible end-to-end)
lefarcen Jul 6, 2026
e508000
fix(web): make the collab demo actually load in the Next runtime
lefarcen Jul 6, 2026
7758014
feat(web): visible comment drift-ladder demo (third vertical)
lefarcen Jul 7, 2026
edc6610
feat(daemon): scaffold Spec E resource-sharing skeleton
Jul 7, 2026
69a20f8
fix(web): drop presence promptly on tab close (sendBeacon leave)
lefarcen Jul 7, 2026
37447d3
feat(daemon): wire presigned blob transport to the resource hub
Jul 7, 2026
a909c95
feat(web): collab session decision seam onto the B/D lanes
lefarcen Jul 7, 2026
3410637
feat(daemon): workspace-context route (B-integration seam)
lefarcen Jul 7, 2026
7246d31
feat(daemon): neutral cloud-drive SDK over the resource hub
Jul 7, 2026
453d7be
feat: D->C sync-intent seam + member-visible ProjectSyncState
lefarcen Jul 7, 2026
c6817da
feat(daemon): member pull trigger (C owns when; E fetches behind adap…
lefarcen Jul 7, 2026
b065c95
feat(contracts): team-resource copy red-line guard (AC-9, D3)
lefarcen Jul 7, 2026
ac2f3e5
feat(web): settings shell role gating helper (D4.3)
lefarcen Jul 7, 2026
626e511
feat(daemon): team-resource state seam + live D3 copy-guard enforceme…
lefarcen Jul 7, 2026
aea60e1
feat(daemon): mount D3 copy-guard on the real plugin duplicate route
lefarcen Jul 7, 2026
8ba29dc
feat(daemon): mount D3 copy-guard on skill edit-shadow route
lefarcen Jul 7, 2026
8b20349
chore(daemon): point resource client at the API (18080), refresh header
Jul 7, 2026
9304e5b
feat(web): wire team presence into the real ProjectView (C frontend i…
lefarcen Jul 7, 2026
1a42887
feat(web): activate comment drift ladder in the real FileViewer for t…
lefarcen Jul 7, 2026
d58413a
feat(web): persist lost comment anchors in the real product (durabili…
lefarcen Jul 7, 2026
5a02eee
feat(tools-serve): add temporary in-memory resource-hub fixture
Jul 7, 2026
d7849d4
Merge remote-tracking branch 'origin/feat/e-resource-hub' into feat/c…
lefarcen Jul 7, 2026
3e6c664
docs(collab): neutral comments — drop internal role/spec references
lefarcen Jul 7, 2026
f09551d
feat(daemon): publish/pull project content through the resource hub
lefarcen Jul 7, 2026
cba1ed9
feat(daemon): derive the resource-hub principal from the workspace co…
lefarcen Jul 7, 2026
02763f6
feat(web): community template gallery at /community (per demo)
lefarcen Jul 7, 2026
c515a79
feat(daemon): share a design system to the team via the resource hub
lefarcen Jul 7, 2026
bec454e
feat(web): team collection + share action in design systems (per demo)
lefarcen Jul 7, 2026
3daf6af
feat(cli): od collab share-design-system + team-design-systems
lefarcen Jul 7, 2026
780478d
feat(web): cloud sign-in callout in settings (per demo)
lefarcen Jul 7, 2026
7c24bf7
feat(web): team workspace switcher in the top bar (per demo)
lefarcen Jul 7, 2026
d9941b0
feat(web): read-only view for a member of a team-shared project
lefarcen Jul 7, 2026
a265038
feat(web): extend read-only view to file actions and chat send
lefarcen Jul 7, 2026
330f716
feat(collab): exempt a shared project's owner from the read-only view
lefarcen Jul 7, 2026
e1c3bbb
feat(web): labeled navigation sidebar (per demo)
lefarcen Jul 7, 2026
cad8a49
feat(daemon): team sharing for plugins and skills (generalize DS share)
lefarcen Jul 7, 2026
dd23af0
feat(cli): generalize od collab resource sharing to plugins and skills
lefarcen Jul 7, 2026
eb89596
feat(web): real team plugins collection with share-to-team
lefarcen Jul 7, 2026
e2b159b
feat(web): team skills collection alongside plugins in the team tab
lefarcen Jul 7, 2026
97dba26
Merge remote-tracking branch 'origin/feat/workspace-team' into feat/c…
lefarcen Jul 7, 2026
8ec9018
feat(collab): mirror the full workspace context onto B's shipped shape
lefarcen Jul 8, 2026
96c7d61
feat(collab): freeze a non-writable workspace read-only via B's permi…
lefarcen Jul 8, 2026
e5c4891
feat(collab): real workspace-context provider backed by B's vela endp…
lefarcen Jul 8, 2026
95962cd
feat(web): team-edition navigation shell driven by the real workspace…
lefarcen Jul 8, 2026
42496ca
feat(collab): vela resource CLI transport for publish/pull (opt-in)
lefarcen Jul 8, 2026
c8d597e
feat(web): role-gated Workspace section shell in Settings
lefarcen Jul 8, 2026
64382e9
feat(web): invite-acceptance client flow (contracts + page + state ma…
lefarcen Jul 8, 2026
49e2a9f
feat: link team management out to the cloud console, not in-client
lefarcen Jul 8, 2026
f01070e
chore(web): drop the in-client invite-acceptance page (moved to the c…
lefarcen Jul 8, 2026
8837fca
fix(collab): build the workspace settings link from the cloud app's r…
lefarcen Jul 8, 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
239 changes: 239 additions & 0 deletions apps/daemon/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ const CONFIG_STRING_FLAGS = new Set(['daemon-url', 'value', 'value-json']);
const CONFIG_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']);
const AMR_STRING_FLAGS = new Set(['daemon-url']);
const AMR_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'refresh']);
const COLLAB_STRING_FLAGS = new Set(['daemon-url', 'project', 'member', 'name', 'role', 'design-system']);
const COLLAB_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']);
const PROJECT_STRING_FLAGS = new Set([
'daemon-url', 'name', 'skill', 'design-system', 'plugin', 'metadata-json',
'pending-prompt', 'project', 'conversation', 'message', 'prompt',
Expand Down Expand Up @@ -311,6 +313,7 @@ const SUBCOMMAND_MAP = {
media: runMedia,
mcp: runMcp,
amr: runAmr,
collab: runCollab,
research: runResearch,
plugin: runPlugin,
ui: runUi,
Expand Down Expand Up @@ -687,6 +690,242 @@ Options:
}
}

// ---------------------------------------------------------------------------
// Subcommand: od collab … (team-edition collaboration)
// ---------------------------------------------------------------------------

function printCollabHelp() {
console.log(`Usage:
od collab status <projectId> [--json]
od collab presence <projectId> [--json]
od collab heartbeat <projectId> --member <id> [--name <name>] [--role owner|admin|member] [--json]
od collab leave <projectId> --member <id> [--json]
od collab changed <projectId> [--json]
od collab publish <projectId> [--json]
od collab share <projectId> [--json]
od collab pull <projectId> [--json]
od collab share-resource <design-systems|plugins|skills> <id> [--json]
od collab team-resources <design-systems|plugins|skills> [--json]
od collab share-design-system <designSystemId> [--json]
od collab team-design-systems [--json]

Team-edition collaboration: presence overlay + sync trigger. The
client is authoritative about whether it is in a shared context, so it drives
the trigger; the daemon coalesces author edits and flushes at a run boundary,
advancing the published head version members poll to learn when to pull.
\`share\` is the team-share intent: it requests the project be published so
members can pull it, and reports the sync state (local_only / pending_upload /
synced / sync_failed). \`share-resource <kind> <id>\` promotes a personal design
system, plugin, or skill into the team scope through the resource hub, and
\`team-resources <kind>\` lists the ones already shared (the \`*-design-system\`
forms are kept as aliases).

Options:
--project <id> Project id (alternative to the positional argument).
--design-system <id> Design system id for share-design-system.
--member <id> Member id for the presence heartbeat / leave.
--name <name> Display name attached to a heartbeat.
--role <role> owner | admin | member.
--json Emit raw JSON.
--daemon-url <url> Override daemon URL.

Examples:
od collab presence p1 --json
od collab heartbeat p1 --member m-42 --name "Ma Shu" --role member
od collab publish p1
od collab share-resource plugins my-plugin --json
od collab team-resources skills --json
od collab share-design-system user:palette-x --json
od collab status p1 --json`);
}

async function runCollab(args) {
const sub = args[0];
if (!sub || sub === 'help' || args.includes('--help') || args.includes('-h')) {
printCollabHelp();
process.exit(!sub ? 2 : 0);
}
const rest = args.slice(1);
let flags;
try {
flags = parseFlags(rest, { string: COLLAB_STRING_FLAGS, boolean: COLLAB_BOOLEAN_FLAGS });
} catch (err) {
console.error(err.message);
process.exit(2);
}
// Team resource sharing (design systems / plugins / skills) is workspace-scoped
// — it takes a resource id, not a project id — so it runs before the project-id
// requirement below. `share-resource <kind> <id>` / `team-resources <kind>` are
// the generic forms; the design-system aliases are kept for compatibility.
const RESOURCE_BASE_PATHS = new Set(['design-systems', 'plugins', 'skills']);
if (
sub === 'share-resource' ||
sub === 'team-resources' ||
sub === 'share-design-system' ||
sub === 'team-design-systems'
) {
const base = await cliDaemonBaseUrl(flags);
const emit = (payload, plain) =>
flags.json ? process.stdout.write(JSON.stringify(payload, null, 2) + '\n') : plain();
const wsRequest = async (method, path, body) => {
let resp;
try {
resp = await fetch(`${base}${path}`, {
method,
...(body !== undefined
? { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
: {}),
});
} catch (err) {
surfaceFetchError(err, base);
process.exit(3);
}
if (!resp.ok) return structuredHttpFailure(resp);
return resp.json();
};

// Resolve the resource kind (URL base path), whether this lists or shares,
// and the target id — from either the aliases or the generic <kind> <id>.
const positionals = positionalArgs(rest, COLLAB_STRING_FLAGS);
let basePath;
let isList;
let resourceId;
if (sub === 'team-design-systems') {
basePath = 'design-systems';
isList = true;
} else if (sub === 'share-design-system') {
basePath = 'design-systems';
isList = false;
resourceId = flags['design-system'] || positionals[0];
} else {
basePath = positionals[0];
if (!RESOURCE_BASE_PATHS.has(basePath)) {
console.error('kind must be one of: design-systems | plugins | skills');
process.exit(2);
}
isList = sub === 'team-resources';
resourceId = positionals[1];
}

if (isList) {
const body = await wsRequest('GET', `/api/workspace/${basePath}/team`);
return emit(body, () => {
const ids = Array.isArray(body?.ids) ? body.ids : [];
if (ids.length === 0) return console.log(`no shared ${basePath}`);
for (const id of ids) console.log(id);
});
}
if (!resourceId) {
console.error('missing <id>');
process.exit(2);
}
const body = await wsRequest(
'POST',
`/api/workspace/${basePath}/${encodeURIComponent(resourceId)}/share`,
);
return emit(body, () =>
console.log(`shared=${body?.shared ?? false}\tversion=${body?.version ?? '-'}`),
);
}

const projectId =
flags.project || positionalArgs(rest, COLLAB_STRING_FLAGS)[0] || process.env.OD_PROJECT_ID;
if (!projectId) {
console.error('missing <projectId> (positional, --project, or OD_PROJECT_ID)');
process.exit(2);
}
const base = await cliDaemonBaseUrl(flags);
const encoded = encodeURIComponent(projectId);

const request = async (method, path, body) => {
let resp;
try {
resp = await fetch(`${base}/api/projects/${encoded}${path}`, {
method,
...(body !== undefined
? { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
: {}),
});
} catch (err) {
surfaceFetchError(err, base);
process.exit(3);
}
if (!resp.ok) return structuredHttpFailure(resp);
return resp.json();
};

const emit = (payload, plain) => {
if (flags.json) return process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
return plain();
};

switch (sub) {
case 'status': {
const body = await request('GET', '/collab/status');
return emit(body, () => {
console.log(`publishedVersion\t${body?.publishedVersion ?? '-'}`);
console.log(`syncState\t${body?.syncState ?? '-'}`);
});
}
case 'share': {
// Team-share intent: request the project be published so members can pull.
const body = await request('POST', '/collab/sync-intent', {
event: 'project_team_share_requested',
projectId,
});
return emit(body, () => console.log(`ok\tsyncState=${body?.syncState ?? '-'}`));
}
case 'pull': {
// Member pull: fetch the published head (E extracts the bytes behind C's trigger).
const body = await request('POST', '/collab/pull');
return emit(body, () => console.log(`pulled\tversion=${body?.version ?? '-'}`));
}
case 'presence': {
const body = await request('GET', '/presence');
return emit(body, () => {
const present = Array.isArray(body?.present) ? body.present : [];
if (present.length === 0) return console.log('no members present');
for (const m of present) console.log(`${m.memberId}\t${m.name ?? '-'}\t${m.role ?? '-'}`);
});
}
case 'heartbeat': {
if (!flags.member) {
console.error('missing --member <id>');
process.exit(2);
}
const memberBody = {
memberId: flags.member,
...(flags.name ? { name: flags.name } : {}),
...(flags.role ? { role: flags.role } : {}),
};
const body = await request('POST', '/presence/heartbeat', memberBody);
return emit(body, () => {
const present = Array.isArray(body?.present) ? body.present : [];
console.log(`ok\t${present.length} present`);
});
}
case 'leave': {
if (!flags.member) {
console.error('missing --member <id>');
process.exit(2);
}
const body = await request('POST', '/presence/leave', { memberId: flags.member });
return emit(body, () => console.log('left'));
}
case 'changed': {
const body = await request('POST', '/collab/changed');
return emit(body, () => console.log('change queued'));
}
case 'publish': {
const body = await request('POST', '/collab/publish');
return emit(body, () => console.log('publish requested'));
}
default:
console.error(`unknown subcommand: od collab ${sub}`);
process.exit(2);
}
}

// ---------------------------------------------------------------------------
// Subcommand: od research …
// ---------------------------------------------------------------------------
Expand Down
92 changes: 92 additions & 0 deletions apps/daemon/src/collab/presence-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Team collaboration presence — the "presence" overlay: who is currently viewing a
// shared project. Decoupled from the resource/sync layer: it is a
// lightweight, poll-friendly heartbeat set, not a realtime-cursor engine (live
// cursors were cut; content is polled).
//
// Pure and timer-free by design. `present()` computes the live set on demand and
// sweeps anyone whose heartbeat has aged past the TTL, so the orchestrator's
// existing poll loop surfaces departures without a background timer. Explicit
// join/leave fire `onChange` immediately so an active viewer's arrival/exit can
// be broadcast without waiting for the next poll.

import type { CollabPresenceMember } from '@open-design/contracts';

// The presence identity shape is the shared contract DTO; keep the local name
// for existing daemon-side imports.
export type PresenceMember = CollabPresenceMember;

export interface CollabPresenceTrackerOptions {
/** A member is considered gone this long after their last heartbeat. */
ttlMs?: number;
now?: () => number;
/** Fired when a project's present set changes via an explicit join or leave. */
onChange?: (result: { projectId: string; present: PresenceMember[] }) => void;
}

interface Entry {
member: PresenceMember;
lastSeen: number;
}

const DEFAULT_TTL_MS = 30_000;

export class CollabPresenceTracker {
private readonly ttlMs: number;
private readonly now: () => number;
private readonly onChange?: CollabPresenceTrackerOptions['onChange'];
private readonly projects = new Map<string, Map<string, Entry>>();

constructor(options: CollabPresenceTrackerOptions = {}) {
this.ttlMs = Math.max(0, options.ttlMs ?? DEFAULT_TTL_MS);
this.now = options.now ?? Date.now;
this.onChange = options.onChange;
}

/** Mark a member present in a project (call on view + on each poll). */
heartbeat(projectId: string, member: PresenceMember): void {
const entries = this.ensure(projectId);
const isNew = !entries.has(member.memberId);
entries.set(member.memberId, { member, lastSeen: this.now() });
if (isNew) this.emit(projectId);
}

/** Explicit departure (tab closed / left the project). */
leave(projectId: string, memberId: string): void {
const entries = this.projects.get(projectId);
if (!entries || !entries.delete(memberId)) return;
if (entries.size === 0) this.projects.delete(projectId);
this.emit(projectId);
}

/** Members present now — sweeps any whose heartbeat aged past the TTL. */
present(projectId: string): PresenceMember[] {
const entries = this.projects.get(projectId);
if (!entries) return [];
const cutoff = this.now() - this.ttlMs;
for (const [memberId, entry] of entries) {
if (entry.lastSeen < cutoff) entries.delete(memberId);
}
if (entries.size === 0) {
this.projects.delete(projectId);
return [];
}
return Array.from(entries.values(), (entry) => entry.member);
}

dispose(): void {
this.projects.clear();
}

private emit(projectId: string): void {
if (this.onChange) this.onChange({ projectId, present: this.present(projectId) });
}

private ensure(projectId: string): Map<string, Entry> {
let entries = this.projects.get(projectId);
if (!entries) {
entries = new Map();
this.projects.set(projectId, entries);
}
return entries;
}
}
Loading
Loading