letagents 0.12.31 → 0.12.33

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.
@@ -1,6 +1,7 @@
1
1
  import { localSupervisedRoomToolAvailable } from "../../../shared/local-supervised-tools.mjs";
2
2
  import { isLocalRoomApi } from "../../../shared/room-api-origin.mjs";
3
3
  import { registerRoomKnowledgeTools } from "./tools/knowledge.js";
4
+ import { registerWakeRuleTools } from "./tools/wake-rules.js";
4
5
  import { registerAgentSessionTools } from "./tools/agent-sessions.js";
5
6
  import { registerMessageTools, registerStatusTools } from "./tools/messages.js";
6
7
  import { registerOnboardingTools } from "./tools/onboarding.js";
@@ -40,6 +41,7 @@ export function registerTools(server, profile = "autonomous_mcp_worker", supervi
40
41
  registerAgentSessionTools(tools);
41
42
  registerRoomInspectionTools(tools);
42
43
  registerRoomKnowledgeTools(tools);
44
+ registerWakeRuleTools(tools);
43
45
  registerStatusTools(tools);
44
46
  if (profile === "autonomous_mcp_worker" || profile === "interactive_desktop")
45
47
  registerWorkspaceTools(tools);
@@ -39,10 +39,39 @@ function getExplicitAgentIdentityStorageKey() {
39
39
  ].filter((value) => Boolean(value?.trim()));
40
40
  return runtimeSignals[0] ?? null;
41
41
  }
42
+ /**
43
+ * Reads the `clientInfo.name` the MCP host sent in `initialize`. Hosts that
44
+ * do not set LETAGENTS_AGENT_IDE are still identifiable this way; without it
45
+ * every Claude Code or OpenCode agent showed up as a generic "Agent". Read
46
+ * lazily: the name exists as soon as the initialize request is handled.
47
+ */
48
+ let readMcpClientName = () => null;
49
+ export function setMcpClientNameSource(source) {
50
+ readMcpClientName = source;
51
+ }
52
+ const MCP_CLIENT_IDE_LABELS = [
53
+ [/^claude[-_ ]?code\b/i, "Claude Code"],
54
+ [/^opencode\b/i, "OpenCode"],
55
+ [/^codex\b/i, "Codex"],
56
+ [/^cursor\b/i, "Cursor"],
57
+ [/^antigravity\b/i, "Antigravity"],
58
+ ];
59
+ export function ideLabelFromMcpClientName(name) {
60
+ const value = String(name ?? "").trim();
61
+ if (!value)
62
+ return null;
63
+ return MCP_CLIENT_IDE_LABELS.find(([pattern]) => pattern.test(value))?.[1] ?? null;
64
+ }
42
65
  export function detectAgentIdeLabel() {
43
66
  if (AGENT_IDE_LABEL) {
44
67
  return toTitleCaseCodename(AGENT_IDE_LABEL);
45
68
  }
69
+ // The host's own name outranks inherited environment: Claude Code started
70
+ // from a Codex shell still carries CODEX_* variables.
71
+ const fromClient = ideLabelFromMcpClientName(readMcpClientName());
72
+ if (fromClient) {
73
+ return fromClient;
74
+ }
46
75
  if (isCodexRuntime()) {
47
76
  return "Codex";
48
77
  }
@@ -54,5 +83,5 @@ export function detectAgentRuntimeLabel() {
54
83
  if (isCodexRuntime()) {
55
84
  return "codex";
56
85
  }
57
- return detectAgentIdeLabel().trim().toLowerCase() || "unknown";
86
+ return detectAgentIdeLabel().trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
58
87
  }
@@ -1,24 +1,9 @@
1
1
  import { parsePositivePgIntegerScopedId } from "../../../shared/message-contracts.mjs";
2
+ import { SUPERVISED_READ_ONLY_TOOLS } from "../../../shared/supervised-read-tools.mjs";
2
3
  import { runWithCurrentSupervisedRoom } from "./runtime/room-state.js";
3
4
  import { completeCurrentSupervisedEffect, authorizeCustodialPolling, executeCurrentSupervisedTool, prepareCurrentSupervisedEffect, } from "./runtime/supervisor-bridge.js";
4
- const READ_TOOLS = new Set([
5
- "get_current_room",
6
- "check_repo",
7
- "check_repo_visibility",
8
- "read_messages",
9
- "wait_for_messages",
10
- "get_board",
11
- "get_board_settings",
12
- "get_room_memory", "get_human_requests", "get_room_guidelines",
13
- "get_room_artifacts",
14
- "get_room_events",
15
- "list_board_intents",
16
- "get_onboarding_status",
17
- "status_local_codex_session",
18
- "rental_list_requests",
19
- ]);
20
5
  export function supervisedToolIsMutation(toolName) {
21
- return !READ_TOOLS.has(toolName);
6
+ return !SUPERVISED_READ_ONLY_TOOLS.has(toolName);
22
7
  }
23
8
  // The desktop daemon's local control protocol intentionally uses small bounded
24
9
  // frames. A read result can be returned live to the provider without copying
@@ -438,7 +438,7 @@ export async function collectThreadContextMessages(input) {
438
438
  return { messages: contextMessages, truncated };
439
439
  }
440
440
  export function registerWaitForMessagesTool(server) {
441
- server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat room (HTTP long-poll). Messages labeled activation.for_current_agent.decision=\"silent\" are skipped for the current worker; when last_observed_message_id is present, use it as cursor progress even if messages is empty. Threaded replies include thread_parent_id/thread.root_message_id; use send_thread_message with that id to keep focused side discussion out of the main room. For multi-hour runs, call in a loop: always pass after_message_id from the last message you processed or last_observed_message_id so an empty result means 'nothing new yet', not 'stop working'. Legacy/manual room workers may send a brief continue instruction when another legacy/manual participant posts a premature 'I will wait' closing line. Never send that nudge to or about a daemon-supervised participant, and daemon-supervised workers must not emit it; their supervisor owns wake and retry. Per-call wait is capped (default max 180s unless LETAGENTS_POLL_MAX_MS is set on API and MCP).", {
441
+ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat room (HTTP long-poll). Messages labeled activation.for_current_agent.decision=\"silent\" are skipped for the current worker; when last_observed_message_id is present, use it as cursor progress even if messages is empty. Threaded replies include thread_parent_id/thread.root_message_id; use send_thread_message with that id to keep focused side discussion out of the main room. For multi-hour runs, call in a loop: always pass after_message_id from the last message you processed or last_observed_message_id so an empty result means 'nothing new yet', not 'stop working'. Legacy/manual room workers may send a brief continue instruction when another legacy/manual participant posts a premature 'I will wait' closing line. Never send that nudge to or about a daemon-supervised participant, and daemon-supervised workers must not emit it; their supervisor owns wake and retry. Per-call wait is capped (default max 180s unless LETAGENTS_POLL_MAX_MS is set on API and MCP). To wait for CI, a review, a task change or a later time, add a wake rule with add_wake_rule: its wake arrives here as a message addressed to you.", {
442
442
  room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
443
443
  after_message_id: z
444
444
  .string()
@@ -260,7 +260,7 @@ export function registerBoardIntentTools(server) {
260
260
  }
261
261
  });
262
262
  server.tool("list_board_intents", "List Board Manager intents for this room, optionally filtered by status.", {
263
- status: z.enum(["pending", "approved", "denied", "expired", "used"]).optional(),
263
+ status: z.enum(["pending", "approved", "denied", "expired", "used", "superseded"]).optional(),
264
264
  room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
265
265
  }, async ({ status, room_id }) => {
266
266
  const targetRoomId = resolveCanonicalRoomId(room_id);
@@ -0,0 +1,96 @@
1
+ import { z } from "zod";
2
+ import { WAKE_RULE_EVENTS, WakeRuleError, } from "../../../../shared/wake-rules.mjs";
3
+ import { isLocalRoomStorageEnabled, roomScopedApiCall } from "../runtime.js";
4
+ import { resolveTaskToolIdentity, resolveTaskToolTarget, taskActorPayload } from "./tasks/context.js";
5
+ import { jsonToolResponse, taskToolError } from "./tasks/response.js";
6
+ const scope = {
7
+ room_id: z.string().optional().describe("Exact room ID. Defaults to this worker’s room."),
8
+ agent_session_id: z.string().optional(),
9
+ };
10
+ const LOCAL_ROOM_NOTE = "Wake rules need a room shared online; this room is stored on this device.";
11
+ async function roomTarget(roomId) {
12
+ const target = resolveTaskToolTarget(roomId);
13
+ if (!target)
14
+ throw new WakeRuleError("Join a room first.");
15
+ const id = target.effectiveRoomId || target.roomId || target.projectId;
16
+ return { target, id, local: await isLocalRoomStorageEnabled(id) };
17
+ }
18
+ function roomPath(suffix) {
19
+ return (id) => `/rooms/${encodeURIComponent(id)}/wake-rules${suffix}`;
20
+ }
21
+ function toolError(error) {
22
+ return taskToolError(error instanceof Error ? error.message : String(error));
23
+ }
24
+ export function registerWakeRuleTools(server) {
25
+ server.tool("add_wake_rule", "Wait for something without polling. Say what you are waiting for, then end your turn: you are woken with a room message addressed to you when it happens, or when the rule expires. "
26
+ + "Use this instead of saying you will check back later. Events and arguments: "
27
+ + "timer {at: ISO time} or {after_ms}; "
28
+ + "task.status_changed {task_id, to?: [statuses]}; "
29
+ + "github.check_completed {branch | pr | mine: true, conclusions?: [...]} (one wake per push, after CI goes quiet); "
30
+ + "github.review_submitted {pr | mine: true, states?: [approved, changes_requested, commented]}; "
31
+ + "github.pr_closed {pr | mine: true, merged_only?}. "
32
+ + "`mine` means the branches and pull requests of tasks you are working on. Rules expire after 24 hours unless you set expires_at (at most 7 days). "
33
+ + "Adding the same rule twice returns the existing one. People see what you are waiting for and can cancel it.", {
34
+ event: z.enum(WAKE_RULE_EVENTS),
35
+ arguments: z.record(z.string(), z.unknown()).optional().describe("What exactly to wait for; see the event list above."),
36
+ note: z.string().max(280).optional().describe("Why you are waiting, shown to people and repeated to you when you wake, e.g. \"merge #1440 once CI passes\"."),
37
+ repeat: z.boolean().optional().describe("Keep watching after the first wake (at most one wake a minute) until it expires. Not for timers."),
38
+ expires_at: z.string().optional().describe("ISO time to stop waiting. You are woken once more if nothing happened by then."),
39
+ ...scope,
40
+ }, async (input) => {
41
+ try {
42
+ const { target, local } = await roomTarget(input.room_id);
43
+ if (local)
44
+ return taskToolError(LOCAL_ROOM_NOTE);
45
+ // The server validates the rule and times it by its own clock, so a
46
+ // timer given as after_ms is not skewed by this machine's clock.
47
+ const rule = { event: input.event, arguments: input.arguments ?? {}, note: input.note, repeat: input.repeat, expires_at: input.expires_at };
48
+ const { identity, agentSession } = await resolveTaskToolIdentity(target, input.agent_session_id);
49
+ return jsonToolResponse(await roomScopedApiCall({
50
+ room_id: target.roomId,
51
+ project_id: target.projectId,
52
+ room_path: roomPath(""),
53
+ project_path: roomPath(""),
54
+ options: { method: "POST", body: JSON.stringify({ rule, ...taskActorPayload(identity, agentSession) }) },
55
+ }));
56
+ }
57
+ catch (error) {
58
+ return toolError(error);
59
+ }
60
+ });
61
+ server.tool("list_wake_rules", "List what agents in this room are waiting for, and wake rules that recently fired, expired or were cancelled. Check this on resume to see what you were waiting for.", { ...scope, agent_key: z.string().optional().describe("Only this agent’s rules.") }, async ({ room_id, agent_key }) => {
62
+ try {
63
+ const { target, local } = await roomTarget(room_id);
64
+ if (local)
65
+ return jsonToolResponse({ note: LOCAL_ROOM_NOTE, active: [], recent: [] });
66
+ const query = agent_key ? `?agent_key=${encodeURIComponent(agent_key)}` : "";
67
+ return jsonToolResponse(await roomScopedApiCall({
68
+ room_id: target.roomId,
69
+ project_id: target.projectId,
70
+ room_path: roomPath(query),
71
+ project_path: roomPath(query),
72
+ }));
73
+ }
74
+ catch (error) {
75
+ return toolError(error);
76
+ }
77
+ });
78
+ server.tool("cancel_wake_rule", "Stop waiting: cancel one of your wake rules when you no longer need it, for example after you checked the result yourself.", { rule_id: z.string().regex(/^wake_[a-z0-9]{1,40}$/), ...scope }, async ({ rule_id, room_id, agent_session_id }) => {
79
+ try {
80
+ const { target, local } = await roomTarget(room_id);
81
+ if (local)
82
+ return taskToolError(LOCAL_ROOM_NOTE);
83
+ const { identity, agentSession } = await resolveTaskToolIdentity(target, agent_session_id);
84
+ return jsonToolResponse(await roomScopedApiCall({
85
+ room_id: target.roomId,
86
+ project_id: target.projectId,
87
+ room_path: roomPath(`/${rule_id}/cancel`),
88
+ project_path: roomPath(`/${rule_id}/cancel`),
89
+ options: { method: "POST", body: JSON.stringify(taskActorPayload(identity, agentSession)) },
90
+ }));
91
+ }
92
+ catch (error) {
93
+ return toolError(error);
94
+ }
95
+ });
96
+ }
@@ -7,8 +7,10 @@ import { attachMcpServer, autoJoinFromContext, shutdownRuntime } from "./server/
7
7
  import { requireValidWorkerBearerRuntime } from "./server/runtime/worker-bearer.js";
8
8
  import { announceProcessExit } from "./server/runtime/process-connection.js";
9
9
  import { executionProfile } from "./server/runtime/execution-profile.js";
10
+ import { setMcpClientNameSource } from "./server/runtime/identity/config.js";
10
11
  import { WORKSPACE_CAPTURE_INSTRUCTIONS } from "./server/tools/workspace.js";
11
12
  import { LETAGENTS_RUNTIME_CONTRACT_ARG, letAgentsRuntimeContract, registerRuntimeReadinessResource, } from "./server/runtime-contract.js";
13
+ const CLIENT_INITIALIZE_WAIT_MS = 5_000;
12
14
  async function main() {
13
15
  if (process.argv.slice(2).includes(LETAGENTS_RUNTIME_CONTRACT_ARG)) {
14
16
  process.stdout.write(`${JSON.stringify(letAgentsRuntimeContract())}\n`);
@@ -25,9 +27,19 @@ async function main() {
25
27
  registerTools(server, profile, supervisedProvider);
26
28
  registerRuntimeReadinessResource(server, profile, supervisedProvider);
27
29
  requireValidWorkerBearerRuntime();
30
+ // Auto-join resolves the agent identity, whose IDE label comes from the
31
+ // host's clientInfo, so wait (briefly) for the host to finish initialize.
32
+ setMcpClientNameSource(() => server.server.getClientVersion()?.name);
33
+ const initialized = new Promise((resolve) => {
34
+ server.server.oninitialized = resolve;
35
+ });
28
36
  const transport = new StdioServerTransport();
29
37
  await server.connect(transport);
30
38
  console.error("🔌 Let Agents Chat MCP server running on stdio (v0.6.0)");
39
+ await Promise.race([
40
+ initialized,
41
+ new Promise((resolve) => setTimeout(resolve, CLIENT_INITIALIZE_WAIT_MS).unref()),
42
+ ]);
31
43
  await autoJoinFromContext();
32
44
  }
33
45
  let exiting = false;
@@ -3,7 +3,11 @@ const IDE_LABELS = new Map([
3
3
  ["agent", "Agent"],
4
4
  ["antigravity", "Antigravity"],
5
5
  ["claude", "Claude"],
6
+ ["claude-code", "Claude Code"],
6
7
  ["codex", "Codex"],
8
+ ["cursor", "Cursor"],
9
+ ["open-model", "Open Model"],
10
+ ["opencode", "OpenCode"],
7
11
  ["orchestrator", "Orchestrator"],
8
12
  ]);
9
13
  function normalizeWhitespace(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letagents",
3
- "version": "0.12.31",
3
+ "version": "0.12.33",
4
4
  "description": "Let Agents Chat \u2014 MCP server for AI agent communication",
5
5
  "type": "module",
6
6
  "main": "dist/mcp/server.js",
@@ -1,5 +1,5 @@
1
1
  export type AgentMessageActivationDecision = "activate" | "silent" | "unclear";
2
- export type AgentMessageActivationReason = "self_message" | "explicit_mention" | "explicit_other_mention" | "broadcast" | "reply_target" | "other_reply_target" | "thread_participant" | "task_owner" | "small_room" | "recent_conversation" | "jev_routed" | "system_event" | "unaddressed";
2
+ export type AgentMessageActivationReason = "self_message" | "explicit_mention" | "explicit_other_mention" | "broadcast" | "reply_target" | "other_reply_target" | "thread_participant" | "task_owner" | "small_room" | "recent_conversation" | "jev_routed" | "wake_rule" | "system_event" | "unaddressed";
3
3
  /** Send-time human fallback only; never use this to re-route historical reads. */
4
4
  export declare function humanConversationFallback(input: {
5
5
  source: string | null;
@@ -22,4 +22,6 @@ export function createKnowledgeRecord(room_id: string, type: KnowledgeType, inpu
22
22
  export function reviseKnowledgeRecord(record: KnowledgeRecord, input: KnowledgeRevisionInput, actor: KnowledgeActor, now?: string): KnowledgeRecord;
23
23
  export function assertKnowledgeReplay(existing: KnowledgeRecord, candidate: KnowledgeRecord): void;
24
24
 
25
+ export function attentionResponseHandle(agentKey: string | null | undefined): string;
25
26
  export function formatAttentionResponse(record: KnowledgeRecord): string;
27
+ export function attentionResponseDisplayText(text: string, agentName: (handle: string) => string | null | undefined): string | null;
@@ -68,7 +68,38 @@ export function assertKnowledgeReplay(existing, candidate) {
68
68
  }
69
69
  }
70
70
 
71
+ /** The `@agent:` handle an answer uses to wake the agent that asked. */
72
+ export function attentionResponseHandle(agentKey) {
73
+ return String(agentKey ?? '').trim().replace(/[A-Z]/g, c => c.toLowerCase()).replace(/[^a-z0-9_.:/-]+/g, '');
74
+ }
75
+
71
76
  export function formatAttentionResponse(record) {
72
- const handle = record.author.kind === 'agent' ? record.author.id.trim().replace(/[A-Z]/g, c => c.toLowerCase()).replace(/[^a-z0-9_.:/-]+/g, '') : '';
77
+ const handle = record.author.kind === 'agent' ? attentionResponseHandle(record.author.id) : '';
73
78
  return `${handle ? `@agent:${handle}\n\n` : ''}Human response (${record.id}):\n\n${record.response.body}`;
74
79
  }
80
+
81
+ const ATTENTION_RESPONSE_TEXT = /^(?:@agent:([a-z0-9_.:/-]+)\n\n)?Human response \([a-zA-Z0-9_-]{8,80}\):\n\n([\s\S]+)$/;
82
+ // A room display name used as a mention: never markdown, never spaces, and
83
+ // no underscore that could open emphasis (leading, or doubled anywhere).
84
+ const MENTION_NAME = /^(?!_)(?!.*__)[A-Za-z0-9._:-]{1,64}$/;
85
+ // Answers that open with a block (fence, list, heading, quote, table, rule or
86
+ // indented code) keep it as a block instead of joining the mention's line.
87
+ const OPENS_WITH_BLOCK = /^(?: {4}|\t| {0,3}(?:`{3}|~{3}|[-*+](?:\s|$)|\d{1,9}[.)](?:\s|$)|#{1,6}(?:\s|$)|>|\||(?:[-*_] *){3,}$))/;
88
+
89
+ /**
90
+ * What people see for a message formatAttentionResponse wrote. The handle and
91
+ * request id route the answer to the agent that asked; the room reads it as a
92
+ * reply to that agent by name, or to a neutral @agent when the room no longer
93
+ * lists it (the visible mention is the record of who was woken). Null for any
94
+ * other text.
95
+ */
96
+ export function attentionResponseDisplayText(text, agentName) {
97
+ const match = ATTENTION_RESPONSE_TEXT.exec(typeof text === 'string' ? text : '');
98
+ if (!match) return null;
99
+ const answer = match[2];
100
+ if (!match[1]) return answer;
101
+ const name = String(agentName(match[1]) ?? '').trim();
102
+ const mention = `@${MENTION_NAME.test(name) ? name : 'agent'}`;
103
+ // trimEnd: a CRLF line keeps its \r, and trailing space must not hide a rule.
104
+ return OPENS_WITH_BLOCK.test(answer.split('\n', 1)[0].trimEnd()) ? `${mention}\n\n${answer}` : `${mention} ${answer}`;
105
+ }
@@ -3,11 +3,14 @@ export const ROOM_RESOURCE_AGENT_WORK: "agent_work";
3
3
  /** Content-free approval-state hint; consumers repair through separately authorized exact reads. */
4
4
  export const ROOM_RESOURCE_AGENT_APPROVAL: "agent_approval";
5
5
  export const ROOM_RESOURCE_EXECUTION_DELEGATION: "execution_delegation";
6
+ /** What agents are waiting for changed; consumers re-read the room's wake rules. */
7
+ export const ROOM_RESOURCE_WAKE_RULES: "wake_rules";
6
8
  /** Protocol-known references; consumers independently choose what they render. */
7
9
  export const ROOM_RESOURCE_INVALIDATION_RESOURCES: readonly [
8
10
  "agent_work",
9
11
  "agent_approval",
10
12
  "execution_delegation",
13
+ "wake_rules",
11
14
  ];
12
15
 
13
16
  export type RoomResourceInvalidationResource =
@@ -5,12 +5,14 @@ export const ROOM_RESOURCE_AGENT_WORK = "agent_work";
5
5
  // authorized exact reads; the pointer never carries approval content.
6
6
  export const ROOM_RESOURCE_AGENT_APPROVAL = "agent_approval";
7
7
  export const ROOM_RESOURCE_EXECUTION_DELEGATION = "execution_delegation";
8
+ export const ROOM_RESOURCE_WAKE_RULES = "wake_rules";
8
9
  // Protocol-known references. Each consumer still decides which surfaces, if
9
10
  // any, react to a supported pointer.
10
11
  export const ROOM_RESOURCE_INVALIDATION_RESOURCES = [
11
12
  ROOM_RESOURCE_AGENT_WORK,
12
13
  ROOM_RESOURCE_AGENT_APPROVAL,
13
14
  ROOM_RESOURCE_EXECUTION_DELEGATION,
15
+ ROOM_RESOURCE_WAKE_RULES,
14
16
  ];
15
17
 
16
18
  function exactKeys(value, keys) {
@@ -0,0 +1,7 @@
1
+ export const NO_REPLY_FAILURE: Readonly<{
2
+ outputLimit: "The model hit its output limit before writing a reply.";
3
+ emptyAnswer: "The model finished without writing a reply.";
4
+ contentFilter: "The model provider's content filter stopped the reply before any text was written.";
5
+ }>;
6
+ export type NoReplyFailureKind = keyof typeof NO_REPLY_FAILURE;
7
+ export function noReplyFailureKind(detail: unknown): NoReplyFailureKind | null;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Why a completed provider turn ended without a room reply. The Open Model
3
+ * adapter reports these as the exact failure detail, and task continuity
4
+ * reads them back to decide whether a follow-up turn can help.
5
+ */
6
+ export const NO_REPLY_FAILURE = Object.freeze({
7
+ outputLimit: "The model hit its output limit before writing a reply.",
8
+ emptyAnswer: "The model finished without writing a reply.",
9
+ contentFilter: "The model provider's content filter stopped the reply before any text was written.",
10
+ });
11
+
12
+ export function noReplyFailureKind(detail) {
13
+ const text = typeof detail === "string" ? detail.trim() : "";
14
+ return Object.keys(NO_REPLY_FAILURE).find((kind) => NO_REPLY_FAILURE[kind] === text) ?? null;
15
+ }
@@ -0,0 +1,13 @@
1
+ export const SCRATCH_WORKSPACE_BRANCH: "main";
2
+ export const RETAINED_REPLACED_ENTRIES: number;
3
+ /** Makes a room's scratch workspace the root of its own empty Git repository. */
4
+ export function ensureScratchWorkspaceRepository(
5
+ workspace: string,
6
+ options?: { exclude?: readonly string[] },
7
+ ): Promise<"created" | "present" | "replaced">;
8
+ /** Null when Git, run with this environment, takes the workspace as a repository. */
9
+ export function probeScratchWorkspaceGit(
10
+ workspace: string,
11
+ environment: Record<string, string | undefined>,
12
+ options?: { timeoutMs?: number; platform?: string; xcodeSelect?: string },
13
+ ): Promise<string | null>;
@@ -0,0 +1,213 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { access, lstat, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+
6
+ /** Branch the empty repository starts on. It has no commits. */
7
+ export const SCRATCH_WORKSPACE_BRANCH = "main";
8
+
9
+ const STAGING = /^\.git\.([1-9][0-9]*)\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/;
10
+ // Our own entries beside the repository stay out of an agent's `git add`.
11
+ const OWN_EXCLUDE = ["/.git.*.tmp", "/.git.replaced.*"];
12
+ const REPLACED = /^\.git\.replaced\.([0-9]+)\.[0-9a-f-]{36}$/;
13
+ let replacedCount = 0;
14
+ /** How many broken `.git` entries are kept aside for inspection. */
15
+ export const RETAINED_REPLACED_ENTRIES = 3;
16
+ // A symbolic ref, or a detached SHA-1 or SHA-256 object name.
17
+ const HEAD_CONTENT = /^(?:ref: refs\/\S+|[0-9a-f]{40}|[0-9a-f]{64})\n?$/;
18
+
19
+ async function kind(path) {
20
+ try {
21
+ const info = await lstat(path);
22
+ return info.isSymbolicLink() ? "link" : info.isDirectory() ? "directory" : info.isFile() ? "file" : "other";
23
+ } catch (error) {
24
+ if (error?.code === "ENOENT") return null;
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ /** The least Git and OpenCode need to take `.git` as a repository. */
30
+ async function isRepository(target) {
31
+ if (await kind(target) !== "directory") return false;
32
+ if (!(await kind(join(target, "HEAD")) === "file"
33
+ && await kind(join(target, "config")) === "file"
34
+ && await kind(join(target, "objects")) === "directory"
35
+ && await kind(join(target, "refs")) === "directory")) return false;
36
+ // Git refuses a directory whose HEAD it cannot read as a ref or an object name.
37
+ const head = await readFile(join(target, "HEAD"), "utf8").catch(() => "");
38
+ return HEAD_CONTENT.test(head);
39
+ }
40
+
41
+ /** Keeps only the newest few broken `.git` entries that were moved aside. */
42
+ async function pruneReplaced(workspace) {
43
+ const replaced = (await readdir(workspace))
44
+ .map((name) => ({ name, at: Number(REPLACED.exec(name)?.[1] ?? NaN) }))
45
+ .filter((entry) => Number.isFinite(entry.at))
46
+ .sort((left, right) => right.at - left.at || (left.name < right.name ? 1 : -1));
47
+ for (const { name } of replaced.slice(RETAINED_REPLACED_ENTRIES)) {
48
+ await rm(join(workspace, name), { recursive: true, force: true });
49
+ }
50
+ }
51
+
52
+ function alive(pid) {
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ } catch (error) {
57
+ return error?.code !== "ESRCH";
58
+ }
59
+ }
60
+
61
+ /** Removes staging directories left by a process that died mid-write. */
62
+ async function removeAbandonedStaging(workspace) {
63
+ for (const name of await readdir(workspace)) {
64
+ const match = STAGING.exec(name);
65
+ if (!match) continue;
66
+ const pid = Number(match[1]);
67
+ if (pid === process.pid || alive(pid)) continue;
68
+ if (await kind(join(workspace, name)) === "directory") {
69
+ await rm(join(workspace, name), { recursive: true, force: true });
70
+ }
71
+ }
72
+ }
73
+
74
+ async function build(workspace, exclude) {
75
+ const staging = join(workspace, `.git.${process.pid}.${randomUUID()}.tmp`);
76
+ await mkdir(staging, { mode: 0o700 });
77
+ for (const directory of ["objects/info", "objects/pack", "refs/heads", "refs/tags", "info"]) {
78
+ await mkdir(join(staging, directory), { recursive: true, mode: 0o700 });
79
+ }
80
+ const file = (path, content) => writeFile(join(staging, path), content, { encoding: "utf8", mode: 0o600, flag: "wx" });
81
+ await file("HEAD", `ref: refs/heads/${SCRATCH_WORKSPACE_BRANCH}\n`);
82
+ // Git records whether the file system ignores case, as `git init` does.
83
+ const ignoreCase = await kind(join(staging, "head")) === "file";
84
+ await file("config", [
85
+ "[core]",
86
+ "\trepositoryformatversion = 0",
87
+ "\tfilemode = true",
88
+ "\tbare = false",
89
+ "\tlogallrefupdates = true",
90
+ ...(ignoreCase ? ["\tignorecase = true"] : []),
91
+ ...(process.platform === "darwin" ? ["\tprecomposeunicode = true"] : []),
92
+ "",
93
+ ].join("\n"));
94
+ await file(join("info", "exclude"), [...OWN_EXCLUDE, ...exclude].map((line) => `${line}\n`).join(""));
95
+ return staging;
96
+ }
97
+
98
+ /**
99
+ * Makes a room's scratch workspace the root of its own empty Git repository.
100
+ *
101
+ * Agent tools treat the root of a Git repository as the edge of a project.
102
+ * Without one, OpenCode 1.18.20 searches every directory from the workspace
103
+ * up to the file system root and imports any plugin it finds there into the
104
+ * process that holds the model provider's key. A repository in the workspace
105
+ * stops that search at the workspace. It has to be the workspace itself: a
106
+ * repository in the parent `room-only` directory would make every other
107
+ * room's workspace part of the same project.
108
+ *
109
+ * The repository is written as files, without running Git. Nothing from the
110
+ * owner's Git configuration, templates, hooks or `GIT_*` variables can reach
111
+ * it, and a machine without Git still gets a workspace. It has no remote and
112
+ * no commits, so nothing can be pushed from it unless an agent adds a remote
113
+ * itself. `exclude` lines go into `.git/info/exclude`, which keeps the
114
+ * daemon's own files out of `git add` and `git clean`.
115
+ *
116
+ * A `.git` that is a directory with `HEAD`, `config`, `objects` and `refs` is
117
+ * left as it is when its HEAD names a ref or an object: it is a repository,
118
+ * possibly one the agent made. Anything else at `.git` (a file, a link, an
119
+ * empty or partial directory, an unreadable HEAD) would let Git and OpenCode
120
+ * look above the workspace again, so it is moved aside to
121
+ * `.git.replaced.<time>.<uuid>` and a repository takes its place; only the
122
+ * newest few moved-aside entries are kept. The repository
123
+ * is built beside the workspace's files and renamed into place, so a crash
124
+ * or a second caller never leaves a half-written `.git`; a staging directory
125
+ * left by a process that died is removed on the next call.
126
+ *
127
+ * @param {string} workspace Absolute path of an existing directory.
128
+ * @param {{ exclude?: readonly string[] }} [options]
129
+ * @returns {Promise<"created" | "present" | "replaced">}
130
+ */
131
+ export async function ensureScratchWorkspaceRepository(workspace, options = {}) {
132
+ const target = join(workspace, ".git");
133
+ await removeAbandonedStaging(workspace);
134
+ if (await isRepository(target)) return "present";
135
+ let replaced = false;
136
+ for (let attempt = 0; attempt < 3; attempt += 1) {
137
+ const staging = await build(workspace, options.exclude ?? []);
138
+ try {
139
+ if (await kind(target) !== null && !await isRepository(target)) {
140
+ // Time, then a per-process count, so that order survives several in one millisecond.
141
+ const order = Date.now() * 10_000 + (replacedCount++ % 10_000);
142
+ await rename(target, join(workspace, `.git.replaced.${order}.${randomUUID()}`)).catch((error) => {
143
+ if (error?.code !== "ENOENT") throw error;
144
+ });
145
+ replaced = true;
146
+ }
147
+ try {
148
+ await rename(staging, target);
149
+ } catch (error) {
150
+ if (!["EEXIST", "ENOTEMPTY", "ENOTDIR", "EISDIR"].includes(error?.code)) throw error;
151
+ }
152
+ // Whoever won, only a verified repository counts.
153
+ if (await isRepository(target)) {
154
+ if (replaced) await pruneReplaced(workspace);
155
+ const ours = await kind(staging) === null;
156
+ return replaced ? "replaced" : ours ? "created" : "present";
157
+ }
158
+ } finally {
159
+ await rm(staging, { recursive: true, force: true });
160
+ }
161
+ }
162
+ throw new Error("The scratch workspace's .git could not be made a repository.");
163
+ }
164
+
165
+ const SYSTEM_GIT = "/usr/bin/git";
166
+
167
+ /** The first executable `git` on a `PATH`, as a shell would find it. */
168
+ async function resolveGit(path) {
169
+ for (const directory of String(path ?? "").split(":")) {
170
+ if (!directory) continue;
171
+ const candidate = join(directory, "git");
172
+ if (await access(candidate, constants.X_OK).then(() => true, () => false)) return candidate;
173
+ }
174
+ return null;
175
+ }
176
+
177
+ /**
178
+ * Checks, with a launch's environment, that Git takes a scratch workspace as
179
+ * a repository: `git -C <workspace> rev-parse --git-dir`, as OpenCode runs
180
+ * it. Without that, OpenCode does not see the repository and the plugin
181
+ * boundary is not in effect. On macOS, when the `git` found is Apple's
182
+ * `/usr/bin/git` and `xcode-select -p` fails, the command line tools are
183
+ * missing: that is reported without running Git, so the check itself never
184
+ * brings up Apple's install prompt. Each command is bounded by `timeoutMs`.
185
+ * Resolves to null when Git works, otherwise to what went wrong.
186
+ *
187
+ * @param {string} workspace
188
+ * @param {Record<string, string | undefined>} environment
189
+ * @param {{ timeoutMs?: number, platform?: string, xcodeSelect?: string }} [options]
190
+ * @returns {Promise<string | null>}
191
+ */
192
+ export async function probeScratchWorkspaceGit(workspace, environment, options = {}) {
193
+ const { execFile } = await import("node:child_process");
194
+ const timeout = options.timeoutMs ?? 2_000;
195
+ const env = {};
196
+ for (const key of ["PATH", "HOME", "XDG_CONFIG_HOME", "LANG", "LC_ALL", "TMPDIR"]) {
197
+ if (environment[key] !== undefined) env[key] = environment[key];
198
+ }
199
+ const run = (file, args) => new Promise((resolve) => {
200
+ execFile(file, args, { env, timeout, maxBuffer: 16 * 1024 }, (error, stdout) => resolve({ error, stdout: String(stdout ?? "") }));
201
+ });
202
+ const git = await resolveGit(env.PATH);
203
+ if (!git) return "no git on the launch's PATH";
204
+ if ((options.platform ?? process.platform) === "darwin" && git === SYSTEM_GIT) {
205
+ const tools = await run(options.xcodeSelect ?? "/usr/bin/xcode-select", ["-p"]);
206
+ if (tools.error) return "the Xcode command line tools are not installed (xcode-select -p failed), so /usr/bin/git cannot run";
207
+ }
208
+ const result = await run(git, ["-C", workspace, "rev-parse", "--git-dir"]);
209
+ if (result.error) return `git rev-parse --git-dir failed: ${String(result.error.message).split("\n")[0]}`;
210
+ const gitDir = result.stdout.trim();
211
+ if (gitDir !== ".git" && gitDir !== join(workspace, ".git")) return `git found its repository at ${gitDir}, not in the workspace`;
212
+ return null;
213
+ }
@@ -0,0 +1,2 @@
1
+ /** Room tools that only read; every other supervised tool call is a journaled mutation. */
2
+ export const SUPERVISED_READ_ONLY_TOOLS: ReadonlySet<string>;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Room tools that only read. A supervised agent's every other tool call is a
3
+ * mutation: the MCP facade asks the daemon to journal it, and the daemon
4
+ * checks that the call it journals was declared as one. Both read this list,
5
+ * so they cannot disagree about which is which.
6
+ */
7
+ export const SUPERVISED_READ_ONLY_TOOLS = new Set([
8
+ "get_current_room",
9
+ "check_repo",
10
+ "check_repo_visibility",
11
+ "read_messages",
12
+ "wait_for_messages",
13
+ "get_board",
14
+ "get_board_settings",
15
+ "get_room_memory",
16
+ "get_human_requests",
17
+ "get_room_guidelines",
18
+ "get_room_artifacts",
19
+ "get_room_events",
20
+ "list_wake_rules",
21
+ "list_board_intents",
22
+ "get_onboarding_status",
23
+ "status_local_codex_session",
24
+ "rental_list_requests",
25
+ ]);