letagents 0.12.35 → 0.12.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ import { promisify } from 'node:util';
6
6
  import { Worker } from 'node:worker_threads';
7
7
  import { parseRoomAgentWorkSummary } from '../../../../shared/room-agent-work.mjs';
8
8
  import { REVIEW_LIMIT, REVIEW_PAGE_SIZE } from '../../../../shared/workspace-review.mjs';
9
- import { releaseWorkspaceTree } from '../../../../shared/workspace-turn-capture.mjs';
9
+ import { captureRemoteTips, releaseWorkspaceTree } from '../../../../shared/workspace-turn-capture.mjs';
10
10
  import { getLocalStatePath, readLocalStateSnapshot, updateLocalState } from '../../local-state/storage.js';
11
11
  import { assertWorkerConnection } from '../../worker-call-context.js';
12
12
  import { encodeRoomIdPath } from '../../room-id.js';
@@ -18,6 +18,8 @@ import { requireValidWorkerBearerRuntime } from './worker-bearer.js';
18
18
  const execute = promisify(execFile);
19
19
  const activeCalls = new Set();
20
20
  let captureWorkerActive = false;
21
+ // What the remotes held when each capture began. Memory only: after a restart the capture is compared plainly.
22
+ const remoteTips = new Map();
21
23
  const validId = (id) => /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(id);
22
24
  const directory = (id) => join(`${getLocalStatePath()}.workspaces`, id);
23
25
  const preparationDirectory = (capture) => {
@@ -70,7 +72,7 @@ async function exclusive(key, operation) {
70
72
  activeCalls.delete(key);
71
73
  }
72
74
  }
73
- async function runCapture(operation, capture, text) {
75
+ async function runCapture(operation, capture, text, tips = null) {
74
76
  if (captureWorkerActive)
75
77
  throw new Error('Another workspace is being captured. Retry shortly; the original baseline is preserved.');
76
78
  captureWorkerActive = true;
@@ -78,7 +80,7 @@ async function runCapture(operation, capture, text) {
78
80
  try {
79
81
  return await new Promise((resolve, reject) => {
80
82
  worker = new Worker(new URL('../../../../shared/mcp-workspace-capture-worker.mjs', import.meta.url), {
81
- execArgv: [], workerData: { operation, capture, text, directory: operation === 'finish' ? preparationDirectory(capture) : null },
83
+ execArgv: [], workerData: { operation, capture, text, remoteTips: tips, directory: operation === 'finish' ? preparationDirectory(capture) : null },
82
84
  });
83
85
  const timeout = setTimeout(() => { reject(new Error('Capture exceeded its time limit. Retry using the same capture_id.')); void worker?.terminate(); }, 120_000);
84
86
  worker.once('message', result => { clearTimeout(timeout); result.error ? reject(new Error(result.error)) : resolve(result); });
@@ -111,6 +113,10 @@ export async function beginWorkspaceCapture(input) {
111
113
  instruction: 'Continue this capture. Keep its capture_id and call publish_workspace_capture when finished.',
112
114
  ...(prior.baseline ? {} : { warning: 'The starting snapshot is unavailable. Exact changes for this piece of work cannot be attributed.' }) };
113
115
  }
116
+ // Asked before the work starts, and not awaited: a slow remote must not delay it. The remotes
117
+ // are the ones this clone configures: nothing outside the clone records where it came from,
118
+ // and an independent worker reports its own work. (The supervisor daemon asks its own record.)
119
+ const tips = captureRemoteTips(workspace);
114
120
  const baseRevision = await git(['rev-parse', '--verify', 'HEAD']);
115
121
  const capture = { capture_id: randomUUID(), api_url: getApiUrl(), room_id: session.room_id,
116
122
  agent_key: session.agent_key, agent_instance_id: session.agent_instance_id, workspace, base_revision: baseRevision,
@@ -125,8 +131,11 @@ export async function beginWorkspaceCapture(input) {
125
131
  delete records[id];
126
132
  records[capture.capture_id] = capture;
127
133
  });
128
- for (const record of obsolete)
134
+ for (const record of obsolete) {
135
+ remoteTips.delete(record.capture_id);
129
136
  await rm(directory(record.capture_id), { recursive: true, force: true });
137
+ }
138
+ remoteTips.set(capture.capture_id, tips);
130
139
  try {
131
140
  const result = await runCapture('begin', capture);
132
141
  capture.baseline = result.baseline ?? null;
@@ -176,7 +185,7 @@ export async function publishWorkspaceCapture(input) {
176
185
  throw new Error('Supply a short summary of at most 400 characters.');
177
186
  const candidate = { ...capture, preparation_id: randomUUID() };
178
187
  try {
179
- await runCapture('finish', candidate, text);
188
+ await runCapture('finish', candidate, text, await remoteTips.get(capture.capture_id) ?? null);
180
189
  await preparedCapture(candidate);
181
190
  // Commit one immutable preparation under the same lock that fences
182
191
  // reconnects. Stale processes only write their own disposable directory.
@@ -247,6 +256,7 @@ export async function publishWorkspaceCapture(input) {
247
256
  if (capture.next_page === total) {
248
257
  capture = { ...capture, phase: 'published' };
249
258
  persist();
259
+ remoteTips.delete(capture.capture_id);
250
260
  await rm(directory(capture.capture_id), { recursive: true, force: true });
251
261
  }
252
262
  return { status: capture.phase === 'published' ? 'published' : 'uploading', capture_id: capture.capture_id,
@@ -259,6 +269,7 @@ export async function publishWorkspaceCapture(input) {
259
269
  if (error instanceof ApiError && error.status === 410) {
260
270
  capture = { ...capture, phase: 'blocked' };
261
271
  persist();
272
+ remoteTips.delete(capture.capture_id);
262
273
  await rm(directory(capture.capture_id), { recursive: true, force: true });
263
274
  }
264
275
  throw error;
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  const MAX_ROOM_REPLY_BYTES = 32 * 1024;
3
3
  /** Select routing only; the daemon remains the sole final-answer publisher. */
4
4
  export function registerReplyThreadTool(server) {
5
- const tool = server.tool("set_reply_thread", "Place your final answer in the activating room message's thread. Takes no arguments, sends no message, and does not finish the turn. Continue working and use your normal final completion when ready. Unavailable for synthetic task continuations or corrections.", {}, async () => { throw new Error("set_reply_thread requires the active daemon-owned room turn."); });
5
+ const tool = server.tool("set_reply_thread", "Opt into a new thread for your final answer only when the user asks for one or a sustained side discussion would distract from the room. Answer ordinary top-level questions in the main room by default, without this tool; existing thread replies stay threaded automatically. Takes no arguments, sends no message, and does not finish the turn. Continue working and use your normal final completion when ready. Unavailable for synthetic task continuations or corrections.", {}, async () => { throw new Error("set_reply_thread requires the active daemon-owned room turn."); });
6
6
  // The legacy raw-shape overload strips unknown keys. Preserve a strict schema
7
7
  // so a caller cannot mistake ignored room/recipient/text arguments for authority.
8
8
  tool.inputSchema = z.object({}).strict();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letagents",
3
- "version": "0.12.35",
3
+ "version": "0.12.36",
4
4
  "description": "Let Agents Chat \u2014 MCP server for AI agent communication",
5
5
  "type": "module",
6
6
  "main": "dist/mcp/server.js",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "license": "BUSL-1.1",
52
52
  "dependencies": {
53
- "@modelcontextprotocol/sdk": "1.27.1",
53
+ "@modelcontextprotocol/sdk": "1.31.0",
54
54
  "drizzle-orm": "0.45.2",
55
55
  "express": "5.2.1",
56
56
  "pg": "8.20.0",
@@ -71,6 +71,8 @@
71
71
  },
72
72
  "fast-uri": "3.1.8",
73
73
  "hono": "4.13.8",
74
- "ip-address": "10.7.2"
74
+ "ip-address": "10.7.2",
75
+ "@modelcontextprotocol/sdk": "1.31.0",
76
+ "proxy-addr": "2.0.8"
75
77
  }
76
78
  }
@@ -18,13 +18,33 @@ export declare function humanConversationFallback(input: {
18
18
  * channel for a managed local worker.
19
19
  */
20
20
  export declare function isUntrustedExternalActivationSource(source: unknown): boolean;
21
+ export interface AgentMessageReplyTurn {
22
+ /** 1-based position in the reply order; only positions 2 and later are sent. */
23
+ position: number;
24
+ count: number;
25
+ /** Labels of the agents before this one that answered, in turn order. */
26
+ prior_speakers: string[];
27
+ }
21
28
  export interface AgentMessageActivation {
22
29
  for_current_agent: {
23
30
  decision: AgentMessageActivationDecision;
24
31
  reason: AgentMessageActivationReason;
25
32
  addressed: boolean;
33
+ turn?: AgentMessageReplyTurn;
34
+ guidance?: string;
26
35
  };
27
36
  }
37
+ /** A receipt as the activation authority sees it. Turn fields are optional. */
38
+ export type AgentMessageActivationReceipt = {
39
+ activation_reason: string;
40
+ turn_position?: number | null;
41
+ turn_count?: number | null;
42
+ prior_speakers?: readonly string[] | null;
43
+ /** Why the held turn was released: "turn", "deadline", "activation" or "skipped". */
44
+ hold_release_reason?: string | null;
45
+ };
46
+ /** The turn note for an agent that answers a shared message after others. */
47
+ export declare function replyTurnGuidance(position: number, count: number, priorSpeakers?: readonly string[], releaseReason?: string | null): string;
28
48
  type MessageLike = {
29
49
  id?: unknown;
30
50
  sender?: unknown;
@@ -85,9 +105,7 @@ export declare function attachAgentMessageActivation<T extends MessageLike>(mess
85
105
  * Only messages that predate routing snapshots keep the lazy per-reader
86
106
  * decision, so legacy backlog mentions still activate rotated sessions.
87
107
  */
88
- export declare function attachAgentMessageActivationsFromReceipts<T extends MessageLike>(messages: readonly T[], identity: ActivationIdentity | null, receiptsMap: ReadonlyMap<number | string, {
89
- activation_reason: string;
90
- }>, snapshotNumbers: ReadonlySet<number>, context?: AgentMessageActivationContext): T[] | Array<T & {
108
+ export declare function attachAgentMessageActivationsFromReceipts<T extends MessageLike>(messages: readonly T[], identity: ActivationIdentity | null, receiptsMap: ReadonlyMap<number | string, AgentMessageActivationReceipt>, snapshotNumbers: ReadonlySet<number>, context?: AgentMessageActivationContext): T[] | Array<T & {
91
109
  activation: AgentMessageActivation;
92
110
  }>;
93
111
  export declare function attachAgentMessageActivations<T extends MessageLike>(messages: readonly T[], identity: ActivationIdentity | null, context?: AgentMessageActivationContext): T[] | Array<T & {
@@ -80,6 +80,7 @@ export function attachAgentMessageActivationsFromReceipts(messages, identity, re
80
80
  decision: "activate",
81
81
  reason: reason || "explicit_mention",
82
82
  addressed: true,
83
+ ...replyTurnActivationFields(receipt),
83
84
  },
84
85
  },
85
86
  };
@@ -103,6 +104,42 @@ export function attachAgentMessageActivationsFromReceipts(messages, identity, re
103
104
  return attachAgentMessageActivation(message, identity, context);
104
105
  });
105
106
  }
107
+ /**
108
+ * The turn note for an agent that answers a shared message after others.
109
+ * Desktop adapters print `for_current_agent` into the prompt verbatim, so this
110
+ * text reaches the model without a client change.
111
+ */
112
+ export function replyTurnGuidance(position, count, priorSpeakers = [], releaseReason = null) {
113
+ const opening = `Turn order: you answer this message in position ${position} of ${count}.`;
114
+ const speakers = priorSpeakers.filter((speaker) => typeof speaker === "string" && speaker.trim());
115
+ if (speakers.length === 0) {
116
+ // Say "did not answer in time" only when that is what happened. A
117
+ // turn opened by a direct activation, or after agents that ended with
118
+ // nothing to say, may still have earlier agents at work.
119
+ return releaseReason === "deadline"
120
+ ? `${opening} The agent before you did not answer in time. Answer now.`
121
+ : `${opening} The agents before you have not answered yet. Cover only what they are unlikely to say.`;
122
+ }
123
+ const names = speakers.length === 1
124
+ ? speakers[0]
125
+ : `${speakers.slice(0, -1).join(", ")} and ${speakers.at(-1)}`;
126
+ const replies = speakers.length === 1 ? "their reply is" : "their replies are";
127
+ return `${opening} ${names} answered before you; ${replies} in the recent room context.`
128
+ + " Do not repeat their points. Add only new points, agree in one short line, or disagree and give the reason.";
129
+ }
130
+ /** Position 1 and parallel receipts add nothing; later positions add turn and guidance. */
131
+ function replyTurnActivationFields(receipt) {
132
+ const position = Number(receipt.turn_position);
133
+ const count = Number(receipt.turn_count);
134
+ if (!Number.isSafeInteger(position) || !Number.isSafeInteger(count) || position < 2 || count < position) {
135
+ return {};
136
+ }
137
+ const priorSpeakers = Array.isArray(receipt.prior_speakers) ? receipt.prior_speakers : [];
138
+ return {
139
+ turn: { position, count, prior_speakers: [...priorSpeakers] },
140
+ guidance: replyTurnGuidance(position, count, priorSpeakers, receipt.hold_release_reason ?? null),
141
+ };
142
+ }
106
143
  export function attachAgentMessageActivations(messages, identity, context = {}) {
107
144
  if (!identity || identity.session_kind !== "worker") {
108
145
  return [...messages];
@@ -9,8 +9,10 @@ import { join, parse } from "node:path";
9
9
  // The owner's config, auth and session history are not modified, and the
10
10
  // project's own skills and AGENTS.md still load. Codex has no launch switch
11
11
  // for its global AGENTS.md or its saved command rules, so those still reach
12
- // the agent. Shared by the desktop and the published MCP package, so it uses
13
- // nothing but Node.
12
+ // the agent. A matching saved rule lets a command out of Codex's sandbox, so
13
+ // the desktop gives an agent at a sandboxed access level a Codex home that has
14
+ // none (codex-agent-home.ts). Shared by the desktop and the published MCP
15
+ // package, so it uses nothing but Node.
14
16
 
15
17
  /** Plugins (with their MCP servers and skills), app connectors, computer and browser use, hooks, memories, notifier. */
16
18
  export const CODEX_OWNER_FEATURE_OVERRIDES = Object.freeze([
@@ -0,0 +1,2 @@
1
+ /** The room tools a Read-only Codex agent may use; every other room tool is refused for it. */
2
+ export const CODEX_READ_ONLY_ROOM_TOOLS: ReadonlySet<string>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The room tools a Read-only Codex agent may use, and the only ones.
3
+ *
4
+ * These read what the room says, and say something in it: a message, a
5
+ * status, visible reasoning, a question for a person, and where the final
6
+ * answer goes. Each takes room identifiers and text, never a folder. None
7
+ * changes the task board, joins or creates a room, submits a review, saves
8
+ * room memory, sets a wake rule, or inspects or writes a repository on this
9
+ * Mac. A tool is allowed only by being named here, so one the room server
10
+ * gains later is refused until someone decides about it.
11
+ *
12
+ * Two places keep the list, and both read it from here. Codex is told to run
13
+ * these without asking and to refuse the others, since a Read-only agent has
14
+ * nobody to ask. The background service refuses the others itself, where
15
+ * every room tool call of such an agent passes, so the limit does not rest on
16
+ * what Codex does with an approval mode.
17
+ */
18
+ export const CODEX_READ_ONLY_ROOM_TOOLS = new Set([
19
+ // What the room says.
20
+ "read_messages", "get_current_room", "get_room_guidelines", "get_room_memory", "get_human_requests",
21
+ "get_board", "get_board_settings", "list_board_intents", "list_wake_rules", "get_room_artifacts", "get_room_events",
22
+ // What the agent says in it.
23
+ "send_message", "send_thread_message", "post_status", "post_reasoning", "request_human_input", "set_reply_thread",
24
+ ]);
@@ -12,7 +12,8 @@ try {
12
12
  if (workerData.operation === 'begin') {
13
13
  parentPort.postMessage({ baseline: await captureWorkspaceTree(capture.workspace, identity) });
14
14
  } else {
15
- const pair = await captureWorkspacePair(capture.workspace, capture.base_revision, capture.baseline, `${identity}:${capture.preparation_id}`);
15
+ // The starting snapshot was taken on `base_revision`. With what the remotes held then, it tells a base update from the work.
16
+ const pair = await captureWorkspacePair(capture.workspace, capture.base_revision, capture.baseline, `${identity}:${capture.preparation_id}`, capture.base_revision, workerData.remoteTips ?? null);
16
17
  pair.contribution.summary = text;
17
18
  const { review, ...preview } = pair;
18
19
  const summary = { version: 3, recorded_state: 'completed', evidence_incomplete: true, elapsed_ms: null,
@@ -5,6 +5,7 @@ export interface GitHubLinkReference extends LinkPreviewReference { repository:
5
5
  export interface MessageLinkPreview extends GitHubLinkReference { title: string; state: "merged" | "closed" | "draft" | "open" }
6
6
  export interface MessageLinkPreviewsResponse { room_id: string; previews: MessageLinkPreview[]; available?: boolean }
7
7
  export function parseGitHubLinkReference(value: unknown): GitHubLinkReference | null;
8
+ export function excludeGitHubEventLink(urls: readonly string[], eventUrl: string | null | undefined): string[];
8
9
  export function normalizePreviewRepository(value: unknown): string | null;
9
10
  export function linkPreviewKey(reference: LinkPreviewReference): string;
10
11
  export function parseLinkPreviewReferences(value: unknown): LinkPreviewReference[] | null;
@@ -16,6 +16,12 @@ export function parseGitHubLinkReference(value) {
16
16
  } catch { return null; }
17
17
  }
18
18
 
19
+ /** The event card already represents its main PR/issue; keep any other links. */
20
+ export function excludeGitHubEventLink(urls, eventUrl) {
21
+ const represented = parseGitHubLinkReference(eventUrl);
22
+ return urls.filter(url => !represented || parseGitHubLinkReference(url)?.url !== represented.url);
23
+ }
24
+
19
25
  export function normalizePreviewRepository(value) {
20
26
  if (typeof value !== "string") return null;
21
27
  const name = value.replace(/^https:\/\//i, "").replace(/^github\.com\//i, "").replace(/\/$/, "");
@@ -46,3 +46,19 @@ export declare function estimateRoomAgentGuidelineTokens(value: unknown): number
46
46
  export declare function normalizeGitHubRoomChatEventKinds(value: unknown): GitHubRoomChatEventKind[] | null;
47
47
  /** The kind a repository event is filed under, or null when it has none. */
48
48
  export declare function githubRoomChatEventKind(event: { kind?: string } | null | undefined): GitHubRoomChatEventKind | null;
49
+
50
+ export type RoomAgentReplyOrder = "sequential" | "parallel";
51
+ export declare const ROOM_AGENT_REPLY_ORDERS: readonly RoomAgentReplyOrder[];
52
+ export declare const DEFAULT_ROOM_AGENT_REPLY_ORDER: RoomAgentReplyOrder;
53
+
54
+ export interface RoomAgentReplyOrderSetting {
55
+ room_id: string;
56
+ /** The order that applies; "parallel" when the room has not chosen. */
57
+ order: RoomAgentReplyOrder;
58
+ /** Whether a room admin chose it, rather than the default applying. */
59
+ chosen: boolean;
60
+ can_manage: boolean;
61
+ }
62
+
63
+ /** A known order, or null when the input is not one. */
64
+ export declare function normalizeRoomAgentReplyOrder(value: unknown): RoomAgentReplyOrder | null;
@@ -106,3 +106,17 @@ export function githubRoomChatEventKind(event) {
106
106
  return null;
107
107
  }
108
108
  }
109
+
110
+ /**
111
+ * How agents answer a message that activates several of them at once (a
112
+ * broadcast, or the small-room fallback). "parallel" — the default when a
113
+ * room has not chosen — wakes them all together; "sequential", which a room
114
+ * admin turns on, gives each agent a turn after the one before has answered.
115
+ */
116
+ export const ROOM_AGENT_REPLY_ORDERS = Object.freeze(["sequential", "parallel"]);
117
+ export const DEFAULT_ROOM_AGENT_REPLY_ORDER = "parallel";
118
+
119
+ /** A known order, or null when the input is not one. */
120
+ export function normalizeRoomAgentReplyOrder(value) {
121
+ return ROOM_AGENT_REPLY_ORDERS.includes(value) ? value : null;
122
+ }
@@ -9,8 +9,10 @@ export function createTypingSender(options: {
9
9
  schedule?: (callback: () => void, ms: number) => any; cancel?: (timer: any) => void;
10
10
  }): { input(nonempty: boolean): void; stop(): void };
11
11
  export function createTypingReceiver(now?: () => number): {
12
- receive(raw: unknown, self: string | null): void; clear(): void; label(): string; nextExpiry(): number | null;
12
+ receive(raw: unknown, self: string | null): void; clear(): void; names(): string[]; label(): string; nextExpiry(): number | null;
13
13
  };
14
- export function createTypingDisplay(update: (label: string) => void, clock?: {
14
+ export function typingLabel(names: readonly string[]): string;
15
+ export function typingSentence(names: readonly string[]): Array<{ text: string; name: boolean }>;
16
+ export function createTypingDisplay(update: (label: string, names: string[]) => void, clock?: {
15
17
  now?: () => number; schedule?: (callback: () => void, ms: number) => any; cancel?: (timer: any) => void;
16
18
  }): { receive(raw: unknown, self: string | null): void; clear(): void };
@@ -60,6 +60,22 @@ export function createTypingSender({ send, clientId, now = Date.now, schedule =
60
60
  };
61
61
  }
62
62
 
63
+ /** Plain-text sentence for assistive technology and compact surfaces. */
64
+ export function typingLabel(names) {
65
+ if (names.length > 2) return 'Several people are typing…';
66
+ return names.length ? `${names.join(' and ')} ${names.length === 1 ? 'is' : 'are'} typing…` : '';
67
+ }
68
+
69
+ /** The same sentence as parts, so a view can emphasise names without building HTML. */
70
+ export function typingSentence(names) {
71
+ const name = text => ({ text, name: true }), plain = text => ({ text, name: false });
72
+ if (names.length === 1) return [name(names[0]), plain(' is typing')];
73
+ if (names.length === 2) return [name(names[0]), plain(' and '), name(names[1]), plain(' are typing')];
74
+ if (names.length < 3) return [];
75
+ const others = names.length - 2;
76
+ return [name(names[0]), plain(', '), name(names[1]), plain(` and ${others} ${others === 1 ? 'other' : 'others'} are typing`)];
77
+ }
78
+
63
79
  /** Sequence guards survive stops briefly; no snapshot or replay. */
64
80
  export function createTypingReceiver(now = Date.now) {
65
81
  const sources = new Map();
@@ -81,16 +97,16 @@ export function createTypingReceiver(now = Date.now) {
81
97
  }
82
98
  return Number.isFinite(earliest) ? earliest : null;
83
99
  },
84
- label() {
100
+ /** One name per account, in a stable order, so a row does not reshuffle between refreshes. */
101
+ names() {
85
102
  const people = new Map();
86
103
  for (const [key, value] of sources) {
87
104
  if (value.forget <= now()) sources.delete(key);
88
105
  else if (value.typing && value.until > now()) people.set(value.account_id, value.name);
89
106
  }
90
- const names = [...people].sort(([a], [b]) => a.localeCompare(b)).map(([, name]) => name);
91
- if (names.length > 2) return 'Several people are typing…';
92
- return names.length ? `${names.join(' and ')} ${names.length === 1 ? 'is' : 'are'} typing…` : '';
107
+ return [...people].sort(([a], [b]) => a.localeCompare(b)).map(([, name]) => name);
93
108
  },
109
+ label() { return typingLabel(this.names()); },
94
110
  };
95
111
  }
96
112
 
@@ -101,7 +117,8 @@ export function createTypingDisplay(update, { now = Date.now, schedule = setTime
101
117
  function refresh() {
102
118
  if (timer !== null) cancel(timer);
103
119
  timer = null;
104
- update(receiver.label());
120
+ const names = receiver.names();
121
+ update(typingLabel(names), names);
105
122
  const expiry = receiver.nextExpiry();
106
123
  if (expiry !== null) timer = schedule(refresh, Math.max(0, expiry - now()));
107
124
  }
@@ -0,0 +1,229 @@
1
+ <template>
2
+ <article class="github-event-card" :class="{ 'is-preview': compact, 'is-room-event': room, 'is-activity': room && !['pull-request', 'issue'].includes(event.kind) }" :data-tone="event.tone" :data-kind="event.kind" :data-status="event.statusLabel">
3
+ <div class="github-event-header">
4
+ <svg class="github-event-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
5
+ <template v-if="event.kind === 'pull-request'">
6
+ <circle cx="4" cy="3" r="1.5" /><circle cx="4" cy="13" r="1.5" /><circle cx="12" cy="13" r="1.5" />
7
+ <path d="M4 4.5v7M12 11.5V6a3 3 0 0 0-3-3H8m2-2L8 3l2 2" />
8
+ </template>
9
+ <template v-else-if="event.kind === 'issue'">
10
+ <circle cx="8" cy="8" r="5.5" /><path d="M8 5v3M8 11h.01" />
11
+ </template>
12
+ <template v-else-if="event.kind === 'review' || event.kind === 'comment'">
13
+ <path d="M3 3h10v8H8l-3 2v-2H3z" />
14
+ <path v-if="event.kind === 'review'" d="m5.5 7 1.5 1.5 3.5-3" />
15
+ <path v-else d="M5.5 6h5M5.5 8h3" />
16
+ </template>
17
+ <template v-else-if="event.kind === 'check'">
18
+ <rect x="2.5" y="2.5" width="11" height="11" rx="3" />
19
+ <path v-if="/^(success|passed)$/i.test(event.statusLabel || '')" d="m5 8 2 2 4-4" />
20
+ <path v-else-if="event.tone === 'rose'" d="m5.5 5.5 5 5m0-5-5 5" />
21
+ <path v-else d="M8 5v3l2 1" />
22
+ </template>
23
+ <path v-else-if="event.kind === 'repository'" d="M2.5 4h4l1.5 2h5.5v6.5h-11z" />
24
+ <template v-else><circle cx="8" cy="8" r="5.5" /><path d="M8 5v3M8 11h.01" /></template>
25
+ </svg>
26
+ <p v-if="room" class="github-event-title">{{ title }}</p>
27
+ <span v-else class="github-event-kind">{{ event.kindLabel }}</span>
28
+ <span v-if="event.statusLabel" class="github-event-status">{{ statusLabel }}</span>
29
+ </div>
30
+
31
+ <div v-if="!room || description" class="github-event-content">
32
+ <p v-if="!room" class="github-event-title">{{ title }}</p>
33
+ <p v-if="description" class="github-event-description">{{ description }}</p>
34
+ </div>
35
+
36
+ <div v-if="event.repository || event.taskId" class="github-event-context">
37
+ <span v-if="event.repository" class="github-event-repository">{{ event.repository }}</span>
38
+ <span v-if="event.repository && event.taskId" class="github-event-separator" aria-hidden="true">·</span>
39
+ <button
40
+ v-if="event.taskId && taskLinkEnabled"
41
+ type="button"
42
+ class="github-event-task"
43
+ :title="`Open ${event.taskId} on the Board`"
44
+ :data-task-reference-id="event.taskId"
45
+ @click="emit('openTask', event.taskId)"
46
+ >{{ event.taskId }}</button>
47
+ <span v-else-if="event.taskId" class="github-event-task">{{ event.taskId }}</span>
48
+ </div>
49
+
50
+ <div v-if="event.url" class="github-event-actions">
51
+ <slot name="actions" />
52
+ <a class="github-event-action" :href="event.url" target="_blank" rel="noopener noreferrer">
53
+ {{ event.urlLabel }}
54
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 11 11 5M5 5h6v6" /></svg>
55
+ </a>
56
+ <button v-if="showEventLink && !compact" type="button" class="github-event-action is-secondary" @click="emit('openEvent', event.url)">
57
+ View in Events
58
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4" /></svg>
59
+ </button>
60
+ </div>
61
+ </article>
62
+ </template>
63
+
64
+ <script setup lang="ts">
65
+ import { computed } from 'vue';
66
+
67
+ const props = defineProps<{
68
+ event: {
69
+ kind: string;
70
+ tone: string;
71
+ kindLabel: string;
72
+ statusLabel: string | null;
73
+ headline: string;
74
+ detail: string | null;
75
+ repository: string | null;
76
+ taskId: string | null;
77
+ url: string | null;
78
+ urlLabel: string;
79
+ };
80
+ compact?: boolean;
81
+ room?: boolean;
82
+ taskLinkEnabled?: boolean;
83
+ showEventLink?: boolean;
84
+ }>();
85
+
86
+ const emit = defineEmits<{
87
+ openTask: [taskId: string];
88
+ openEvent: [url: string];
89
+ }>();
90
+
91
+ // PR/issue details carry the artifact title; other events carry supporting text.
92
+ const titleFirst = computed(() => Boolean(props.event.detail) && ['pull-request', 'issue'].includes(props.event.kind));
93
+ const title = computed(() => titleFirst.value ? props.event.detail : props.event.headline);
94
+ const description = computed(() => titleFirst.value ? props.event.headline : props.event.detail);
95
+ const statusLabel = computed(() => {
96
+ const label = props.event.statusLabel || '';
97
+ return label.charAt(0).toUpperCase() + label.slice(1);
98
+ });
99
+ </script>
100
+
101
+ <style scoped>
102
+ .github-event-card {
103
+ --event-accent: var(--text-secondary);
104
+ display: grid;
105
+ gap: 10px;
106
+ width: 100%;
107
+ max-width: 560px;
108
+ min-width: 0;
109
+ box-sizing: border-box;
110
+ padding: 14px 16px 10px;
111
+ border: 1px solid var(--border-strong);
112
+ border-radius: var(--radius-lg, 12px);
113
+ background: var(--bg-card);
114
+ color: var(--text);
115
+ font-family: var(--font-sans);
116
+ }
117
+ .github-event-card:not(.is-preview) { container-type: inline-size; }
118
+ .github-event-card[data-tone="violet"] { --event-accent: var(--task-assigned); }
119
+ .github-event-card[data-tone="emerald"] { --event-accent: var(--green-text); }
120
+ .github-event-card[data-tone="rose"] { --event-accent: var(--red-text); }
121
+ .github-event-card[data-tone="amber"] { --event-accent: var(--amber-text); }
122
+ .github-event-card[data-tone="sky"] { --event-accent: var(--blue-text); }
123
+ .github-event-header { display: flex; align-items: center; gap: 8px; min-width: 0; }
124
+ .github-event-icon { width: 16px; height: 16px; flex: none; color: var(--event-accent); }
125
+ .github-event-kind { color: var(--text-secondary); font-size: 12px; font-weight: 500; line-height: 1.5; }
126
+ .github-event-status {
127
+ margin-left: auto;
128
+ padding: 2px 7px;
129
+ border-radius: 5px;
130
+ background: color-mix(in srgb, var(--event-accent) 10%, transparent);
131
+ color: var(--text);
132
+ font-size: 11px;
133
+ font-weight: 500;
134
+ line-height: 1.5;
135
+ overflow-wrap: anywhere;
136
+ }
137
+ .github-event-content { display: grid; gap: 4px; min-width: 0; }
138
+ .github-event-title { margin: 0; color: var(--text); font-size: 14px; font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; }
139
+ .github-event-description { margin: 0; color: var(--text-secondary); font-size: 12px; line-height: 1.5; white-space: pre-line; overflow-wrap: anywhere; }
140
+ .github-event-context { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; color: var(--text-secondary); font-size: 11px; line-height: 1.5; }
141
+ .github-event-repository { overflow-wrap: anywhere; }
142
+ .github-event-task { display: inline-flex; align-items: center; min-height: 24px; padding: 0 4px; border: 0; border-radius: 4px; background: transparent; color: var(--text-secondary); font: inherit; font-family: var(--font-mono); }
143
+ button.github-event-task { cursor: pointer; }
144
+ .github-event-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; padding-top: 8px; border-top: 1px solid var(--border); }
145
+ .github-event-action,
146
+ .github-event-actions :deep(.pull-request-changes-button) {
147
+ display: inline-flex;
148
+ align-items: center;
149
+ justify-content: center;
150
+ gap: 5px;
151
+ min-height: 32px;
152
+ max-width: 100%;
153
+ box-sizing: border-box;
154
+ padding: 5px 9px;
155
+ border: 1px solid transparent;
156
+ border-radius: 6px;
157
+ background: transparent;
158
+ color: var(--text-secondary);
159
+ font: 500 12px/1.5 var(--font-sans);
160
+ text-align: center;
161
+ text-decoration: none;
162
+ cursor: pointer;
163
+ transition: background-color 120ms ease, color 120ms ease;
164
+ }
165
+ .github-event-action:first-child,
166
+ .github-event-actions :deep(.pull-request-changes-button) { border-color: var(--border); background: var(--accent-dim); color: var(--text); }
167
+ @container (min-width: 420px) {
168
+ .github-event-action.is-secondary { margin-left: auto; }
169
+ }
170
+ .github-event-action svg { width: 14px; height: 14px; flex: none; }
171
+ @media (hover: hover) and (pointer: fine) {
172
+ .github-event-action:hover,
173
+ .github-event-actions :deep(.pull-request-changes-button:hover),
174
+ button.github-event-task:hover { background: var(--accent-hover); color: var(--text); text-decoration: none; }
175
+ }
176
+ .github-event-action:active,
177
+ .github-event-actions :deep(.pull-request-changes-button:active),
178
+ button.github-event-task:active { background: var(--accent-active); color: var(--text); transition: none; }
179
+ .github-event-action:focus-visible,
180
+ .github-event-actions :deep(.pull-request-changes-button:focus-visible),
181
+ button.github-event-task:focus-visible { outline: 2px solid var(--blue-text); outline-offset: 2px; }
182
+ .github-event-card.is-preview { margin-top: 6px; gap: 8px; padding: 12px 14px 8px; }
183
+ @media (pointer: coarse) {
184
+ .github-event-action,
185
+ .github-event-actions :deep(.pull-request-changes-button),
186
+ button.github-event-task { min-height: 44px; }
187
+ }
188
+ @media (prefers-reduced-motion: reduce) {
189
+ .github-event-action,
190
+ .github-event-actions :deep(.pull-request-changes-button) { transition: none; }
191
+ }
192
+ @media (prefers-contrast: more) {
193
+ .github-event-card { border-color: var(--text-secondary); }
194
+ .github-event-status { outline: 1px solid var(--event-accent); }
195
+ }
196
+ /* Room events share the conversation column, with flat, semantic borders. */
197
+ .github-event-card.is-room-event {
198
+ max-width: 100%;
199
+ gap: 6px;
200
+ padding: 11px 14px 8px;
201
+ border-color: color-mix(in srgb, var(--event-accent) 45%, var(--border-strong));
202
+ border-radius: 9px;
203
+ background: color-mix(in srgb, var(--bg-card) 65%, var(--bg));
204
+ box-shadow: none;
205
+ }
206
+ .is-room-event .github-event-title { font-size: 13px; font-weight: 550; }
207
+ .is-room-event .github-event-description { font-size: 11px; }
208
+ .is-room-event .github-event-actions { border: 0; padding-top: 0; gap: 8px; }
209
+ .is-room-event .github-event-action,
210
+ .is-room-event .github-event-actions :deep(.pull-request-changes-button) {
211
+ min-height: 26px; padding: 3px 0; border: 0; background: transparent; font-size: 11px;
212
+ }
213
+ .is-activity { display: flex; flex-wrap: wrap; align-items: center; column-gap: 12px; }
214
+ .is-activity .github-event-header { flex: 1 1 220px; }
215
+ .is-activity .github-event-kind { display: none; }
216
+ .is-activity .github-event-status { margin-left: 0; }
217
+ .is-activity .github-event-content { display: none; }
218
+ .is-activity .github-event-title { font-size: 12px; font-weight: 500; color: var(--text-secondary); }
219
+ .is-activity .github-event-description, .is-activity .github-event-repository, .is-activity .github-event-separator { display: none; }
220
+ .is-activity .github-event-context,
221
+ .is-activity .github-event-actions { grid-column: 1 / -1; }
222
+ @media (pointer: coarse) {
223
+ .is-room-event .github-event-action,
224
+ .is-room-event .github-event-actions :deep(.pull-request-changes-button) { min-height: 44px; }
225
+ }
226
+ @media (prefers-contrast: more) {
227
+ .github-event-card.is-room-event { border-color: var(--event-accent); }
228
+ }
229
+ </style>