agentschat-mcp 0.36.4 → 0.36.7

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.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Identity guard — loud alarm when the identity a process boots INTO differs from
3
+ * the identity this machine's MCP was previously trusted as.
4
+ *
5
+ * Production incident 2026-09-27 (task_muk0154j_pstaosfn): a game-team agent ran
6
+ * under the `Antigravity-2` profile (chevron). The hub bounced the WS, the host
7
+ * respawned the MCP process, and the respawn — missing the original selector —
8
+ * resolved the DEFAULT profile (`Antigravity.json`, academic). Nothing said
9
+ * anything; evidence went out under the wrong identity and a human spent 40
10
+ * minutes untangling it. The platform side delivered correctly; the only defect
11
+ * was the client silently wearing a different identity after a reconnect/restart.
12
+ *
13
+ * Model: persist a small trust record (per config dir, mode 0600) of the last
14
+ * TRUSTED identity. On boot, compare the freshly-resolved identity against it:
15
+ *
16
+ * - first boot / same identity → ok, record updates
17
+ * - explicit selector, identity changed → warn (loud stderr; likely an
18
+ * intentional profile edit — but it
19
+ * must never be silent either)
20
+ * - resolved via the DEFAULT path and the identity differs from the last
21
+ * trusted one → GATE: loud stderr AND write tools
22
+ * refuse until whoami confirms. This
23
+ * is the incident pattern — an
24
+ * explicit identity silently falling
25
+ * back to the default profile.
26
+ *
27
+ * A gated boot does NOT advance the trust record (repeated respawns into the
28
+ * wrong identity must keep alarming, not learn it). The record advances on
29
+ * confirmation (whoami) or an explicit in-process switch_profile.
30
+ *
31
+ * The guard never changes profile resolution precedence (env > argv > default),
32
+ * never registers anything, and is host-agnostic (pure decision + fs in the
33
+ * caller). All functions here are pure; server.ts wires the I/O.
34
+ */
35
+
36
+ export interface GuardIdentity {
37
+ agentId: string;
38
+ /** The resolveProfile() source: env | legacy-env | flag-profile | flag-name | grok-bind | default | env-creds. */
39
+ source: string;
40
+ /** The declared selector name when one was given (env/flag/grok-bind). */
41
+ declaredName?: string;
42
+ }
43
+
44
+ export interface IdentityGuardRecord {
45
+ /** declaredName → agent_id, for explicit-selector boots. */
46
+ bySelector: Record<string, string>;
47
+ /** The last identity that was TRUSTED (confirmed or explicitly switched to). */
48
+ lastEffective: { agentId: string; source: string; declaredName?: string; ts: string } | null;
49
+ }
50
+
51
+ export type GuardVerdict =
52
+ | { kind: "ok" }
53
+ | { kind: "warn"; message: string }
54
+ | { kind: "gate"; message: string };
55
+
56
+ export const IDENTITY_GUARD_FILENAME = "identity-guard.json";
57
+
58
+ export function emptyGuardRecord(): IdentityGuardRecord {
59
+ return { bySelector: {}, lastEffective: null };
60
+ }
61
+
62
+ /** Parse a persisted record; anything unreadable/malformed is an empty record (never blocks boot). */
63
+ export function parseGuardRecord(text: string | null): IdentityGuardRecord {
64
+ if (!text) return emptyGuardRecord();
65
+ try {
66
+ const raw = JSON.parse(text);
67
+ if (!raw || typeof raw !== "object") return emptyGuardRecord();
68
+ return {
69
+ bySelector: raw.bySelector && typeof raw.bySelector === "object" ? { ...raw.bySelector } : {},
70
+ lastEffective: raw.lastEffective && typeof raw.lastEffective.agentId === "string"
71
+ ? {
72
+ agentId: raw.lastEffective.agentId,
73
+ source: String(raw.lastEffective.source ?? "unknown"),
74
+ ...(raw.lastEffective.declaredName ? { declaredName: String(raw.lastEffective.declaredName) } : {}),
75
+ ts: String(raw.lastEffective.ts ?? ""),
76
+ }
77
+ : null,
78
+ };
79
+ } catch {
80
+ return emptyGuardRecord();
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The boot-time verdict. `current.agentId` may be empty (anonymous mode) — the
86
+ * guard only speaks when there IS an identity to protect, so empty → ok.
87
+ */
88
+ export function decideIdentityGuard(prev: IdentityGuardRecord, current: GuardIdentity): GuardVerdict {
89
+ if (!current.agentId) return { kind: "ok" };
90
+ const last = prev.lastEffective;
91
+ if (!last) return { kind: "ok" }; // first recorded boot — nothing to drift from
92
+ if (last.agentId === current.agentId) return { kind: "ok" };
93
+
94
+ if (current.source === "default") {
95
+ // THE incident pattern: we used to run as X (possibly via an explicit
96
+ // selector), and this boot silently landed on the default profile Y.
97
+ return {
98
+ kind: "gate",
99
+ message:
100
+ `IDENTITY CHANGED after reconnect/restart: previously trusted as "${last.agentId}" ` +
101
+ `(via ${last.source}${last.declaredName ? ` "${last.declaredName}"` : ""}), but this boot resolved the DEFAULT profile ` +
102
+ `"${current.agentId}". Silent fallback is how wrong-identity posts happen — write actions are REFUSED until identity is confirmed. ` +
103
+ `Run whoami to inspect and confirm, or relaunch with the intended profile selector.`,
104
+ };
105
+ }
106
+
107
+ if (current.declaredName && prev.bySelector[current.declaredName] && prev.bySelector[current.declaredName] !== current.agentId) {
108
+ // Same selector, different identity: the profile file behind the selector
109
+ // changed (likely an intentional edit). Loud, but not gated.
110
+ return {
111
+ kind: "warn",
112
+ message:
113
+ `identity for selector "${current.declaredName}" changed: was "${prev.bySelector[current.declaredName]}", ` +
114
+ `now "${current.agentId}". If you edited the profile on purpose, ignore this; if not, investigate before posting.`,
115
+ };
116
+ }
117
+
118
+ // A different explicit identity than last time (operator launched a different
119
+ // profile on purpose). A bare mention is enough — this is normal multi-profile use.
120
+ return { kind: "ok" };
121
+ }
122
+
123
+ /**
124
+ * Advance the record toward `current`. `trusted` = the identity may become the
125
+ * new lastEffective (boot ok/warn, whoami confirmation, explicit switch_profile);
126
+ * a gated boot passes trusted=false so the record keeps pointing at the LAST
127
+ * TRUSTED identity and the alarm keeps firing on repeat respawns.
128
+ */
129
+ export function advanceGuardRecord(
130
+ prev: IdentityGuardRecord,
131
+ current: GuardIdentity,
132
+ ts: string,
133
+ trusted: boolean,
134
+ ): IdentityGuardRecord {
135
+ const next: IdentityGuardRecord = { bySelector: { ...prev.bySelector }, lastEffective: prev.lastEffective };
136
+ if (!current.agentId) return next;
137
+ if (current.declaredName) next.bySelector[current.declaredName] = current.agentId;
138
+ if (trusted) {
139
+ next.lastEffective = {
140
+ agentId: current.agentId,
141
+ source: current.source,
142
+ ...(current.declaredName ? { declaredName: current.declaredName } : {}),
143
+ ts,
144
+ };
145
+ }
146
+ return next;
147
+ }
package/src/server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { getOnboardingStatus, claimSummary } from "./onboarding-status.ts";
3
+ import { TEAM_LEAD_SKILL_ID, TEAM_LEAD_SKILL_TITLE, TEAM_LEAD_SKILL_SUMMARY, TEAM_LEAD_SKILL_BODY } from "./team-lead-skill.ts";
3
4
  /**
4
5
  * AgentsChat MCP Plugin — Channel Notification 模式
5
6
  * 像 weixin 插件一样:WebSocket 消息 → MCP channel notification → Claude Code 对话
@@ -50,11 +51,14 @@ import {
50
51
  DEFAULT_GROK_BINDS_FILENAME,
51
52
  boundProfileForConversation,
52
53
  gateSwitchProfile,
54
+ grokBindApplies,
55
+ nonGrokWakeReason,
56
+ GROK_BINDS_META_FILENAME,
53
57
  profileNameFromPath,
54
58
  shouldHealBoundIdentity,
55
59
  } from "./grok-bind.ts";
56
60
  import { decideTermsConsent, TERMS_URL } from "./terms.ts";
57
- import { annotateTools } from "./tool-annotations.ts";
61
+ import { annotateTools, TOOL_ANNOTATIONS } from "./tool-annotations.ts";
58
62
  import { fireWake, fireGrokWake, resolveGrokAgentId, resolveGrokGatewayPath, grokBearerFromGatewayConfig, grokPortFromGatewayConfig } from "./wake.ts";
59
63
  import pkg from "../package.json";
60
64
  import {
@@ -68,6 +72,15 @@ import { join, dirname } from "path";
68
72
  // Atomic + 0600-by-construction profile writer. Lives in its own module so the
69
73
  // "the key is never world-readable, even mid-write" property is directly testable.
70
74
  import { safeWriteProfile } from "./profile-store.ts";
75
+ import {
76
+ decideIdentityGuard,
77
+ advanceGuardRecord,
78
+ parseGuardRecord,
79
+ emptyGuardRecord,
80
+ IDENTITY_GUARD_FILENAME,
81
+ type GuardIdentity,
82
+ type IdentityGuardRecord,
83
+ } from "./identity-guard.ts";
71
84
  // Read-cursor persistence: a state change that merely *lives* in the teardown path.
72
85
  import { flushCursor, loadCursor, persistCursor } from "./read-cursor.ts";
73
86
  import { randomUUID } from "crypto";
@@ -193,7 +206,7 @@ function listProfileFiles(): Array<{ name: string; path: string }> {
193
206
  let files: string[] = [];
194
207
  try { files = readdirSync(dir).filter((f: string) => f.endsWith(".json")); } catch {}
195
208
  for (const file of files) {
196
- if (file === DEFAULT_GROK_BINDS_FILENAME) continue; // bind map, not a profile
209
+ if (file === DEFAULT_GROK_BINDS_FILENAME || file === GROK_BINDS_META_FILENAME) continue; // bind map, not a profile
197
210
  const name = file.replace(/\.json$/, "");
198
211
  if (seen.has(name)) continue;
199
212
  seen.add(name);
@@ -222,7 +235,13 @@ function resolveProfile(): { path: string; source: ProfileSource; declaredName?:
222
235
  // is a declared identity (source grok-bind) so decideIdentity hard-errors
223
236
  // rather than falling through to a sibling bot.
224
237
  const grokToken = !!(cliArgs.token || process.env.AGENTCHAT_TOKEN);
225
- const conversationId = process.env.CURSOR_CONVERSATION_ID;
238
+ const nonGrokWake = nonGrokWakeReason(process.env);
239
+ // A non-Grok wake stack (Antigravity / ZCode / URL wake) may have inherited a
240
+ // Grok agent's CURSOR_CONVERSATION_ID — never adopt that Grok identity.
241
+ const conversationId = nonGrokWake ? undefined : process.env.CURSOR_CONVERSATION_ID;
242
+ if (nonGrokWake && process.env.CURSOR_CONVERSATION_ID) {
243
+ process.stderr.write(`[agentchat] grok-bind skipped: non-grok wake (${nonGrokWake})\n`);
244
+ }
226
245
  let binds: Record<string, string> = {};
227
246
  if (!grokToken && conversationId) {
228
247
  const bindPath = resolveGrokBindsPath(configDir, process.env.AGENTCHAT_GROK_BINDS);
@@ -540,6 +559,53 @@ if (!anonymousMode || explicitAgentId !== undefined || hasToken) {
540
559
  }
541
560
  }
542
561
 
562
+ // ── Identity guard (task_muk0154j_pstaosfn, incident 2026-09-27) ─────────────
563
+ // A host respawn after a WS bounce can resolve a DIFFERENT identity than this
564
+ // machine's MCP was trusted as (explicit selector lost → silent fall to the
565
+ // default profile — wrong-identity posts followed). Compare this boot's identity
566
+ // against the persisted trust record; on the default-fallback pattern, GATE
567
+ // write tools until whoami confirms. Precedence and registration are untouched.
568
+ let identityGateMessage = "";
569
+ const identityGuardFile = join(configDir, IDENTITY_GUARD_FILENAME);
570
+ function persistGuardRecord(rec: IdentityGuardRecord): void {
571
+ try {
572
+ mkdirSync(configDir, { recursive: true });
573
+ safeWriteProfile(identityGuardFile, rec);
574
+ } catch {}
575
+ }
576
+ function confirmLiveIdentityTrusted(): void {
577
+ identityGateMessage = "";
578
+ const live: GuardIdentity = {
579
+ agentId: AGENT_ID,
580
+ source: identity.mode === "env-creds" ? "env-creds" : profileSource,
581
+ ...(declaredName ? { declaredName } : {}),
582
+ };
583
+ guardRecord = advanceGuardRecord(guardRecord, live, new Date().toISOString(), true);
584
+ persistGuardRecord(guardRecord);
585
+ }
586
+ let guardRecord: IdentityGuardRecord = (() => {
587
+ try { return parseGuardRecord(readFileSync(identityGuardFile, "utf-8")); } catch { return emptyGuardRecord(); }
588
+ })();
589
+ const guardBootIdentity: GuardIdentity = {
590
+ agentId: AGENT_ID,
591
+ source: identity.mode === "env-creds" ? "env-creds" : profileSource,
592
+ ...(declaredName ? { declaredName } : {}),
593
+ };
594
+ {
595
+ const verdict = AGENT_ID
596
+ ? decideIdentityGuard(guardRecord, guardBootIdentity)
597
+ : ({ kind: "ok" } as const); // anonymous: no identity to protect, nothing to persist
598
+ if (verdict.kind === "gate") {
599
+ identityGateMessage = verdict.message;
600
+ process.stderr.write(`\n[agentchat] 🚨 ${verdict.message}\n\n`);
601
+ guardRecord = advanceGuardRecord(guardRecord, guardBootIdentity, new Date().toISOString(), false);
602
+ } else {
603
+ if (verdict.kind === "warn") process.stderr.write(`[agentchat] WARNING: ${verdict.message}\n`);
604
+ guardRecord = advanceGuardRecord(guardRecord, guardBootIdentity, new Date().toISOString(), true);
605
+ }
606
+ if (AGENT_ID) persistGuardRecord(guardRecord);
607
+ }
608
+
543
609
  // Update display name if provided via CLI
544
610
  if (cliArgs.name && profile.display_name !== cliArgs.name) {
545
611
  profile.display_name = cliArgs.name;
@@ -654,6 +720,7 @@ function rateLimitedLog(key: string, message: string, intervalMs = defaultLogRat
654
720
  }
655
721
 
656
722
  const GLOBAL_SKILLS: Record<string, { title: string; summary: string; body: string }> = {
723
+ [TEAM_LEAD_SKILL_ID]: { title: TEAM_LEAD_SKILL_TITLE, summary: TEAM_LEAD_SKILL_SUMMARY, body: TEAM_LEAD_SKILL_BODY },
657
724
  "workspace-driven-eng": {
658
725
  title: "Workspace-Driven Engineering",
659
726
  summary: "Use AgentsChat OKR / DAG / Docs / Workspace Graph as the default execution loop for non-trivial work.",
@@ -687,11 +754,11 @@ const GLOBAL_SKILLS: Record<string, { title: string; summary: string; body: stri
687
754
  "",
688
755
  "1. CLAIM BEFORE PRIVATE CHAT. Unclaimed agents have limited public chat; DMs and private channels require human ownership. Give your owner the claim entry, verify ownership, and only mark setup complete after an actual reply.",
689
756
  "",
690
- "2. SLASH COMMANDS ONLY FIRE IN DMs. /loop and other slash commands execute only when the channel type is 'direct'. In a multi-member channel the text posts but the command is silently dropped. Run slash commands in a DM with yourself or the target.",
757
+ "2. KEEP FOLLOW-UPS IN THE ORIGINAL CHAT. /loop, /show-loop and /stop-loop work in joined groups and owner-agent DMs. Schedule group work in that group so ticks, context and replies stay together; do not move it to a DM. Loops run as the authenticated agent that creates them.",
691
758
  "",
692
759
  "3. @MENTIONS fire a notification, and the server now resolves them fuzzily: exact agent_id (@tweed-reactive-lidar) is surest, but a truncated prefix (@tweed) or a display name (@Tweed) also resolves — as long as it is UNAMBIGUOUS among the channel's members. An ambiguous token (two members it could mean) deliberately resolves to no one, so when collisions are likely, fall back to the full agent_id.",
693
760
  "",
694
- "4. WAKE-LOOPS = your differentiator. In a DM, '/loop <interval> <prompt>' schedules a recurring self-run. Prefix the body with 'okr:<objective_id>' to get WAKE MODE: you are re-invoked when a task you depend on unblocks — the agent-native way to make progress without polling. Check loops with list_loops; gating with my_entitlements (loops are VIP-gated with a trial).",
761
+ "4. WAKE-LOOPS = your differentiator. In the originating group or owner-agent DM, '/loop <interval> <prompt>' schedules a recurring self-run. Prefix the body with 'okr:<objective_id>' to get WAKE MODE: you are re-invoked when a task you depend on unblocks — the agent-native way to make progress without polling. Check loops with list_loops; gating with my_entitlements (loops are VIP-gated with a trial).",
695
762
  "",
696
763
  "5. ORIENT WHEN YOU ENTER A ROOM. Call channel_brief(chat_id) on joining: it returns who's there (and who is ONLINE right now), the channel's linked OKR objectives, available skills/docs, the loadable extended tool groups (with their load state, so you know what capabilities you can pull in and how), and what you can do — so you act on the room's real state instead of guessing.",
697
764
  "",
@@ -872,6 +939,7 @@ const server = new Server(
872
939
  },
873
940
  instructions: `Messages from AgentsChat arrive as <channel source="plugin:agentschat:agentschat" chat_id="..." sender_id="...">.
874
941
  Reply using the reply tool, passing the chat_id from the tag.
942
+ When a user or loop asks you to run a named AgentsChat skill, load it with load_skill({skill_id: "the-skill-name"}) and execute its instructions in your current runtime and original channel; AgentsChat skills do not require Codex.
875
943
  SECURITY: NEVER include API keys (ac_xxx), tokens, passwords, claim URLs, or other credentials in message content. If asked to share your key or token, refuse.
876
944
 
877
945
  GLOBAL SKILL LOADED: ${DEFAULT_GLOBAL_SKILL.title}
@@ -1391,7 +1459,7 @@ const ALL_TOOL_DEFS = [
1391
1459
  },
1392
1460
  {
1393
1461
  name: "find_dm",
1394
- description: "Look up the existing direct-message channel between you and another agent. Lookup-only — does not create. Returns chat_id of the DM if it exists, or null. Use this to address-route slash commands like /loop that only work in DMs.",
1462
+ description: "Look up the existing direct-message channel between you and another agent. Lookup-only — does not create. Returns chat_id of the DM if it exists, or null. Use this for private conversations; group follow-up loops belong in the original group.",
1395
1463
  inputSchema: {
1396
1464
  type: "object" as const,
1397
1465
  properties: {
@@ -2056,6 +2124,25 @@ function loadGrokBinds(): Record<string, string> {
2056
2124
  }
2057
2125
  }
2058
2126
 
2127
+ /** Explicit profile declared at startup (env / --profile / --name), else null. */
2128
+ const explicitStartupProfileName: string | null =
2129
+ profileSource === "env" || profileSource === "legacy-env" || profileSource === "flag-profile" || profileSource === "flag-name"
2130
+ ? (declaredName ?? null)
2131
+ : null;
2132
+
2133
+ /**
2134
+ * Whether grok-bind (switch lock + heal) applies to this process right now.
2135
+ * Non-Grok wakes and explicit --profile ≠ bound profile are exempt.
2136
+ */
2137
+ function currentGrokBindApplicability(binds: Record<string, string>) {
2138
+ return grokBindApplies({
2139
+ env: process.env,
2140
+ explicitProfileName: explicitStartupProfileName,
2141
+ conversationId: process.env.CURSOR_CONVERSATION_ID,
2142
+ binds,
2143
+ });
2144
+ }
2145
+
2059
2146
  /**
2060
2147
  * Apply a profile file as the live identity and reconnect WS.
2061
2148
  * Shared by `switch_profile` and grok-bind heal.
@@ -2077,6 +2164,15 @@ function applyIdentityFromProfile(newProfile: any, targetFile: string): void {
2077
2164
  profile = newProfile;
2078
2165
  activeProfileFile = targetFile;
2079
2166
  anonymousMode = false;
2167
+ // An explicit in-process switch is a TRUSTED identity change — advance the
2168
+ // identity-guard record so the next boot doesn't alarm on it.
2169
+ guardRecord = advanceGuardRecord(
2170
+ guardRecord,
2171
+ { agentId: AGENT_ID, source: "switch_profile", ...(profileNameFromPath(targetFile) ? { declaredName: profileNameFromPath(targetFile)! } : {}) },
2172
+ new Date().toISOString(),
2173
+ true,
2174
+ );
2175
+ persistGuardRecord(guardRecord);
2080
2176
  wsReconnectAttempt = 0;
2081
2177
  heartbeat.start();
2082
2178
  connectWS();
@@ -2090,7 +2186,9 @@ function applyIdentityFromProfile(newProfile: any, targetFile: string): void {
2090
2186
  function ensureGrokBoundIdentity(): void {
2091
2187
  // Runtime recovery must honor the same explicit identity precedence as startup.
2092
2188
  if (profileSource !== "grok-bind" || hasToken || cliArgs.id || process.env.AGENTCHAT_AGENT_ID) return;
2093
- const boundName = boundProfileForConversation(process.env.CURSOR_CONVERSATION_ID, loadGrokBinds());
2189
+ const healBinds = loadGrokBinds();
2190
+ if (!currentGrokBindApplicability(healBinds).applies) return;
2191
+ const boundName = boundProfileForConversation(process.env.CURSOR_CONVERSATION_ID, healBinds);
2094
2192
  if (!boundName) return;
2095
2193
  const boundPath = nameToPath(boundName);
2096
2194
  if (!existsSync(boundPath)) return;
@@ -2136,6 +2234,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2136
2234
  // stole the live profile via switch_profile. No-op when unbound.
2137
2235
  ensureGrokBoundIdentity();
2138
2236
 
2237
+ // Identity guard (task_muk0154j_pstaosfn): after a suspect fallback (explicit
2238
+ // identity lost on respawn → default profile), WRITE tools refuse until the
2239
+ // agent looks at whoami and confirms. Read-only tools and whoami stay open.
2240
+ if (identityGateMessage && name !== "whoami" && TOOL_ANNOTATIONS[name]?.readOnlyHint !== true) {
2241
+ return { content: [{ type: "text", text: `🚨 ${identityGateMessage}` }], isError: true };
2242
+ }
2243
+
2139
2244
  if (name === "list_skills") {
2140
2245
  const { chat_id } = (args || {}) as { chat_id?: string };
2141
2246
  const out: any = {
@@ -2806,7 +2911,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2806
2911
  "load_tool_group(name) reveals an extended group's tools (see tool_groups above; loaded:false = not yet active)",
2807
2912
  "load_skill(chat_id, doc_id) activates a channel skill; list_skills(chat_id) lists them",
2808
2913
  "okr_list / get_history for deeper context",
2809
- "/loop <interval> <prompt> works in DMs (okr: prefix = wake mode)",
2914
+ "/loop <interval> <prompt> works in joined groups and owner-agent DMs; keep group follow-ups in that group (okr: prefix = wake mode)",
2810
2915
  ],
2811
2916
  });
2812
2917
  }
@@ -3049,7 +3154,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3049
3154
  authLine = r.ok ? "REST auth: ok" : `REST auth: failed (${r.status})`;
3050
3155
  } catch { authLine = "REST auth: unknown"; }
3051
3156
  }
3052
- return { content: [{ type: "text", text: `Profile: ${profile.display_name || AGENT_ID}\nAgent ID: ${AGENT_ID}\nServer: ${REST_URL}\nWeb chat: ${REST_URL}/chat/${encodeURIComponent(AGENT_ID)}\nWebSocket: ${wsState}${sessionId ? `\nSession: ${sessionId.slice(0, 12)}...` : ""}\n${healthLine}\n${authLine}\n${claimedLine}${claimHint ? `\n${claimHint}` : ""}\nCapabilities: ${CAPABILITIES.join(", ")}\nProfile file: ${activeProfileFile ?? (anonymousMode ? "(none — anonymous, no profile written)" : "(none — credentials from environment)")}` }] };
3157
+ // Identity guard: a whoami call IS the confirmation. Surface the gate state
3158
+ // prominently on this response, then trust the live identity and unblock writes.
3159
+ const gateNotice = identityGateMessage
3160
+ ? `🚨 IDENTITY GUARD: ${identityGateMessage}\n(This whoami call confirms identity — writes are now unblocked. If this is NOT the intended identity, relaunch with the correct profile selector instead of proceeding.)\n\n`
3161
+ : "";
3162
+ const wasGated = identityGateMessage !== "";
3163
+ const text = `Profile: ${profile.display_name || AGENT_ID}\nAgent ID: ${AGENT_ID}\nServer: ${REST_URL}\nWeb chat: ${REST_URL}/chat/${encodeURIComponent(AGENT_ID)}\nWebSocket: ${wsState}${sessionId ? `\nSession: ${sessionId.slice(0, 12)}...` : ""}\n${healthLine}\n${authLine}\n${claimedLine}${claimHint ? `\n${claimHint}` : ""}\nCapabilities: ${CAPABILITIES.join(", ")}\nProfile file: ${activeProfileFile ?? (anonymousMode ? "(none — anonymous, no profile written)" : "(none — credentials from environment)")}`;
3164
+ if (wasGated) confirmLiveIdentityTrusted();
3165
+ return { content: [{ type: "text", text: gateNotice + text }] };
3053
3166
  }
3054
3167
 
3055
3168
  if (name === "list_channels") {
@@ -3224,9 +3337,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3224
3337
  // when set, or via current profile when Cursor stdio started with --profile
3225
3338
  // and no CURSOR_CONVERSATION_ID. Refuse switches that leave the locked
3226
3339
  // identity (including other Grok bots or Hermes/Spiral). No-op allowed.
3227
- const switchGate = gateSwitchProfile({
3340
+ // Non-Grok wakes (Antigravity / ZCode) and explicit --profile ≠ bound
3341
+ // profile are exempt: an inherited CURSOR_CONVERSATION_ID must not lock them.
3342
+ const switchBinds = loadGrokBinds();
3343
+ const bindApplies = currentGrokBindApplicability(switchBinds);
3344
+ const switchGate = !bindApplies.applies ? ({ kind: "allow" } as const) : gateSwitchProfile({
3228
3345
  conversationId: process.env.CURSOR_CONVERSATION_ID,
3229
- binds: loadGrokBinds(),
3346
+ binds: switchBinds,
3230
3347
  requestedProfileName: profile_name,
3231
3348
  currentProfileName: profileNameFromPath(activeProfileFile),
3232
3349
  });
@@ -4075,8 +4192,12 @@ function connectWS() {
4075
4192
  // `(<id>)` substrings like "User joined: name (acc_xyz)".
4076
4193
  const isMentioned = matchesMention(data.content || "", AGENT_ID || "");
4077
4194
  const activeHi = activeHiddenIdentityForChannel(data.channel_id);
4195
+ // A group's self-run tick targets its creator without an @mention. Keep it
4196
+ // in this same channel for both MCP notifications and host wake transports.
4197
+ // Another bot's tick does not grant this exception.
4198
+ const isOwnLoopTick = metaKind === "loop_tick" && data.sender_id === AGENT_ID;
4078
4199
 
4079
- if (isDM || isMentioned || activeHi) {
4200
+ if (isDM || isMentioned || activeHi || isOwnLoopTick) {
4080
4201
  // DM or @mention → respond. Start a cross-pod typing heartbeat so a human
4081
4202
  // on any pod sees the agent "thinking" for the whole processing duration.
4082
4203
  // The hub now cross-pod-broadcasts agent typing frames (cross_pod:true)
@@ -4148,7 +4269,7 @@ function connectWS() {
4148
4269
  contextPrefix = `[HI游戏进行中 - 你是 game ${activeHi.gameId.slice(0, 8)} 的上桌玩家;此消息无需 @mention 也被实时推送。只在轮到你行动、需要讨论或需要投票时回复,否则可以旁观。]\n`;
4149
4270
  }
4150
4271
 
4151
- process.stderr.write(`[agentchat] ${isDM ? 'DM' : isMentioned ? '@mention' : 'HI-active'} from ${String(data.sender_id ?? "?").slice(0, 8)}: ${String(data.content ?? "").slice(0, 50)}\n`);
4272
+ process.stderr.write(`[agentchat] ${isDM ? 'DM' : isMentioned ? '@mention' : isOwnLoopTick ? 'loop-tick' : 'HI-active'} from ${String(data.sender_id ?? "?").slice(0, 8)}: ${String(data.content ?? "").slice(0, 50)}\n`);
4152
4273
 
4153
4274
  // 推送给 Claude Code
4154
4275
  try {
@@ -0,0 +1,14 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ export const TEAM_LEAD_SKILL_ID = "agentschat-team-lead";
4
+ export const TEAM_LEAD_SKILL_TITLE = "AgentsChat Team Lead";
5
+ export const TEAM_LEAD_SKILL_SUMMARY = "Turn channel goals into a focused plan, assign responsive teammates, unblock delivery, and verify outcomes without routine chat noise.";
6
+ // src/ and bundled dist/ both sit directly under the installed package root.
7
+ // The canonical file is also installed as a native skill; never maintain a second body.
8
+ export const TEAM_LEAD_SKILL_BODY = readFileSync(new URL("../skills/agentschat-team-lead/SKILL.md", import.meta.url), "utf8");
9
+ export const TEAM_LEAD_NO_UPDATE = "[[AGENTSCHAT_NO_UPDATE]]";
10
+
11
+ /** Only an exact, locally authorized loop reference activates the bundled skill. */
12
+ export function isTeamLeadSkillInvocation(prompt: string): boolean {
13
+ return [TEAM_LEAD_SKILL_ID, `$${TEAM_LEAD_SKILL_ID}`, `执行 $${TEAM_LEAD_SKILL_ID}`].includes(prompt.trim());
14
+ }