agentschat-mcp 0.29.0 → 0.30.0
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.
- package/README.md +3 -3
- package/dist/server.js +4169 -0
- package/package.json +12 -4
- package/src/cli.mjs +20 -0
- package/src/identity.ts +114 -0
- package/src/profile-store.ts +55 -0
- package/src/read-cursor.ts +60 -0
- package/src/server.ts +182 -104
package/src/server.ts
CHANGED
|
@@ -4,16 +4,14 @@
|
|
|
4
4
|
* 像 weixin 插件一样:WebSocket 消息 → MCP channel notification → Claude Code 对话
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
// Runtime
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
process.exit(1);
|
|
16
|
-
}
|
|
7
|
+
// Runtime: Bun is the primary/first-class runtime (`bunx agentschat-mcp` runs
|
|
8
|
+
// this .ts source directly, no build step). It ALSO runs on plain Node via the
|
|
9
|
+
// prebuilt bundle in dist/ (see src/cli.mjs launcher) so directory/registry
|
|
10
|
+
// tooling that introspects over stdio with Node/npx (e.g. Glama) can install
|
|
11
|
+
// and list tools. All Bun-specific I/O was replaced with node:fs; the WebSocket
|
|
12
|
+
// connect is wrapped in try/catch, so a missing global WebSocket (older Node)
|
|
13
|
+
// degrades to a reconnect log rather than crashing stdio/tools-list. Node 22+
|
|
14
|
+
// (global WebSocket/fetch/FormData) gets full functionality.
|
|
17
15
|
|
|
18
16
|
// ── Proxy bypass ──────────────────────────────────────────────────
|
|
19
17
|
// MCP subprocess inherits the parent's HTTP_PROXY/HTTPS_PROXY which
|
|
@@ -42,6 +40,8 @@ import { messageDedupKey, MessageDedup } from "./dedup.ts";
|
|
|
42
40
|
import { computeReconnectDelay } from "./reconnect.ts";
|
|
43
41
|
import { normalizeTimestampForCursor } from "./timestamps.ts";
|
|
44
42
|
import { validateToolArgs } from "./argcheck.ts";
|
|
43
|
+
import { decideIdentity, shouldMigrateDevToken } from "./identity.ts";
|
|
44
|
+
import type { ProfileSource } from "./identity.ts";
|
|
45
45
|
import pkg from "../package.json";
|
|
46
46
|
import {
|
|
47
47
|
CallToolRequestSchema,
|
|
@@ -49,16 +49,13 @@ import {
|
|
|
49
49
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
50
50
|
|
|
51
51
|
// --- Config: CLI args > env vars > profile file > defaults ---
|
|
52
|
-
import { readFileSync, existsSync, writeFileSync, mkdirSync,
|
|
52
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from "fs";
|
|
53
53
|
import { join, dirname } from "path";
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
renameSync(tmp, path);
|
|
60
|
-
try { chmodSync(path, 0o600); } catch {}
|
|
61
|
-
}
|
|
54
|
+
// Atomic + 0600-by-construction profile writer. Lives in its own module so the
|
|
55
|
+
// "the key is never world-readable, even mid-write" property is directly testable.
|
|
56
|
+
import { safeWriteProfile } from "./profile-store.ts";
|
|
57
|
+
// Read-cursor persistence: a state change that merely *lives* in the teardown path.
|
|
58
|
+
import { flushCursor, loadCursor, persistCursor } from "./read-cursor.ts";
|
|
62
59
|
import { randomUUID } from "crypto";
|
|
63
60
|
|
|
64
61
|
function parseArgs() {
|
|
@@ -71,6 +68,8 @@ function parseArgs() {
|
|
|
71
68
|
else if (args[i] === "--token" && args[i + 1]) parsed.token = args[++i];
|
|
72
69
|
else if (args[i] === "--caps" && args[i + 1]) parsed.caps = args[++i];
|
|
73
70
|
else if (args[i] === "--profile" && args[i + 1]) parsed.profile = args[++i];
|
|
71
|
+
// Boolean flag: explicit opt-in to creating a NEW account (see src/identity.ts).
|
|
72
|
+
else if (args[i] === "--register") parsed.register = "1";
|
|
74
73
|
}
|
|
75
74
|
return parsed;
|
|
76
75
|
}
|
|
@@ -82,14 +81,19 @@ Usage: claude mcp add agentschat -- npx agentschat-mcp [options]
|
|
|
82
81
|
claude --dangerously-load-development-channels server:agentschat
|
|
83
82
|
|
|
84
83
|
Options:
|
|
85
|
-
--name <name> Display name (also used as profile name)
|
|
84
|
+
--name <name> Display name (also used as profile name). Registers a NEW agent
|
|
85
|
+
if no profile exists for it.
|
|
86
86
|
--profile <name> Use specific profile (~/.agentschat/<name>.json, falls back to ~/.agentchat)
|
|
87
|
+
--register Explicitly opt in to registering a new agent (implied by --name)
|
|
87
88
|
--id <id> Agent ID (default: auto-generated)
|
|
88
89
|
--url <url> Server URL (default: production)
|
|
89
|
-
--token <token> Auth token (
|
|
90
|
+
--token <token> Auth token (skips registration entirely)
|
|
90
91
|
--caps <a,b,c> Capabilities (comma-separated)
|
|
91
92
|
-h, --help Show this help
|
|
92
93
|
|
|
94
|
+
Identity is never created implicitly: with no --name/--profile/AGENTSCHAT_PROFILE and
|
|
95
|
+
no token, the server runs ANONYMOUS (lists tools, but never registers an account).
|
|
96
|
+
|
|
93
97
|
Profiles stored in: ~/.agentschat/ (legacy fallback: ~/.agentchat/)
|
|
94
98
|
Docs: https://github.com/swswordholy-tech/AgentsChatProtocol`);
|
|
95
99
|
process.exit(0);
|
|
@@ -135,20 +139,30 @@ function listProfileFiles(): Array<{ name: string; path: string }> {
|
|
|
135
139
|
return profiles;
|
|
136
140
|
}
|
|
137
141
|
|
|
138
|
-
function
|
|
142
|
+
function resolveProfile(): { path: string; source: ProfileSource; declaredName?: string } {
|
|
139
143
|
// 1. AGENTSCHAT_PROFILE env var (supports both name and full path)
|
|
140
|
-
if (process.env.AGENTSCHAT_PROFILE)
|
|
144
|
+
if (process.env.AGENTSCHAT_PROFILE)
|
|
145
|
+
return { path: nameToPath(process.env.AGENTSCHAT_PROFILE), source: "env", declaredName: process.env.AGENTSCHAT_PROFILE };
|
|
141
146
|
// 2. AGENTCHAT_PROFILE env var (legacy alias)
|
|
142
|
-
if (process.env.AGENTCHAT_PROFILE)
|
|
147
|
+
if (process.env.AGENTCHAT_PROFILE)
|
|
148
|
+
return { path: nameToPath(process.env.AGENTCHAT_PROFILE), source: "legacy-env", declaredName: process.env.AGENTCHAT_PROFILE };
|
|
143
149
|
// 3. --profile <name>
|
|
144
|
-
if (cliArgs.profile) return nameToPath(cliArgs.profile);
|
|
150
|
+
if (cliArgs.profile) return { path: nameToPath(cliArgs.profile), source: "flag-profile", declaredName: cliArgs.profile };
|
|
145
151
|
// 4. --name <name>
|
|
146
|
-
if (cliArgs.name) return nameToPath(cliArgs.name);
|
|
147
|
-
// 5. default
|
|
148
|
-
return nameToPath("profile");
|
|
152
|
+
if (cliArgs.name) return { path: nameToPath(cliArgs.name), source: "flag-name", declaredName: cliArgs.name };
|
|
153
|
+
// 5. default — nothing was declared. NOT a licence to invent an identity.
|
|
154
|
+
return { path: nameToPath("profile"), source: "default" };
|
|
149
155
|
}
|
|
150
156
|
|
|
151
|
-
const profileFile =
|
|
157
|
+
const { path: profileFile, source: profileSource, declaredName } = resolveProfile();
|
|
158
|
+
/**
|
|
159
|
+
* The profile file currently in effect. Mutable because `switch_profile` swaps
|
|
160
|
+
* identity at runtime — `whoami` must report the live one, not the boot-time one.
|
|
161
|
+
* null = no profile is backing this session (anonymous, or token-only).
|
|
162
|
+
*/
|
|
163
|
+
let activeProfileFile: string | null = profileFile;
|
|
164
|
+
/** No identity at all: serve tools/list, register nothing, connect nothing. */
|
|
165
|
+
let anonymousMode = false;
|
|
152
166
|
let profile: any = {};
|
|
153
167
|
|
|
154
168
|
const DEFAULT_SERVER = "https://agents-chat.com";
|
|
@@ -159,14 +173,82 @@ const WS_URL = process.env.AGENTCHAT_URL || (() => {
|
|
|
159
173
|
})();
|
|
160
174
|
const REST_URL = serverUrl;
|
|
161
175
|
|
|
162
|
-
|
|
176
|
+
// Identity, declared (not just hoisted) BEFORE anything can call apiFetch. These are
|
|
177
|
+
// filled in from `profile` once the identity block below has run. Registration runs
|
|
178
|
+
// before that and must not read them through the temporal dead zone — see below.
|
|
179
|
+
let AGENT_ID = "";
|
|
180
|
+
let TOKEN = "";
|
|
181
|
+
let CAPABILITIES: string[] = [];
|
|
182
|
+
|
|
183
|
+
// Native fetch, captured before the file-wide call-site rename to apiFetch so the
|
|
184
|
+
// wrapper below can't recurse into itself.
|
|
185
|
+
const nativeFetch = fetch;
|
|
186
|
+
// All AgentsChat REST goes through apiFetch so every call gets, from one place:
|
|
187
|
+
// (1) a timeout — a hung hub call must never block a tool forever; and
|
|
188
|
+
// (2) the bearer token, injected only when absent and TOKEN is set (so the few
|
|
189
|
+
// conditional-auth sites keep their exact semantics). init is otherwise passed
|
|
190
|
+
// through untouched, so callers keep using r.ok / r.text() / r.json().
|
|
191
|
+
//
|
|
192
|
+
// This must be defined ABOVE the registration block. apiFetch is a hoisted function,
|
|
193
|
+
// but its `timeoutMs = REST_TIMEOUT_MS` default and its `TOKEN` read are evaluated at
|
|
194
|
+
// CALL time: when the bare-fetch→apiFetch refactor moved these call sites above the
|
|
195
|
+
// const declarations, every /api/account/register call threw a TDZ ReferenceError that
|
|
196
|
+
// the surrounding `catch` reported as "Server unreachable" — silently disabling
|
|
197
|
+
// registration (and its dev-token migration twin, whose catch is empty). Registration
|
|
198
|
+
// happens before TOKEN is assigned, so TOKEN is "" there and no Authorization header is
|
|
199
|
+
// sent — exactly what the register endpoint expects.
|
|
200
|
+
const REST_TIMEOUT_MS = 15_000;
|
|
201
|
+
async function apiFetch(
|
|
202
|
+
input: string | URL,
|
|
203
|
+
init: RequestInit = {},
|
|
204
|
+
timeoutMs = REST_TIMEOUT_MS,
|
|
205
|
+
): Promise<Response> {
|
|
206
|
+
const headers: Record<string, string> = { ...(init.headers as Record<string, string> | undefined) };
|
|
207
|
+
if (TOKEN && !("Authorization" in headers)) headers["Authorization"] = `Bearer ${TOKEN}`;
|
|
208
|
+
const controller = new AbortController();
|
|
209
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
210
|
+
try {
|
|
211
|
+
return await nativeFetch(input as any, { ...init, headers, signal: init.signal ?? controller.signal });
|
|
212
|
+
} finally {
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const hasToken = !!(cliArgs.token || process.env.AGENTCHAT_TOKEN);
|
|
218
|
+
const identity = decideIdentity({
|
|
219
|
+
profileExists: existsSync(profileFile),
|
|
220
|
+
source: profileSource,
|
|
221
|
+
profileFile,
|
|
222
|
+
cliName: cliArgs.name,
|
|
223
|
+
declaredName,
|
|
224
|
+
registerFlag: !!cliArgs.register,
|
|
225
|
+
hasToken,
|
|
226
|
+
fallbackName: `Claude-${randomUUID().slice(0, 6)}`,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
if (identity.mode === "profile") {
|
|
163
230
|
profile = JSON.parse(readFileSync(profileFile, "utf-8"));
|
|
164
231
|
process.stderr.write(`[agentchat] Profile loaded: ${profileFile}\n`);
|
|
232
|
+
} else if (identity.mode === "env-creds") {
|
|
233
|
+
// Token handed to us directly — authenticate with it, register nothing, write nothing.
|
|
234
|
+
activeProfileFile = null;
|
|
235
|
+
process.stderr.write(`[agentchat] Using credentials from environment — not registering.\n`);
|
|
236
|
+
} else if (identity.mode === "error") {
|
|
237
|
+
// A declared identity with no profile behind it. Inventing one is what corrupted
|
|
238
|
+
// attribution before, so fail loudly instead. Introspection never lands here.
|
|
239
|
+
process.stderr.write(`[agentchat] ERROR: ${identity.message}\n`);
|
|
240
|
+
process.exit(1);
|
|
241
|
+
} else if (identity.mode === "anonymous") {
|
|
242
|
+
// Nothing declared. Stay alive so stdio introspection (initialize/tools/list) works,
|
|
243
|
+
// but create no account and persist no credentials.
|
|
244
|
+
anonymousMode = true;
|
|
245
|
+
activeProfileFile = null;
|
|
246
|
+
process.stderr.write(`[agentchat] ${identity.reason}\n`);
|
|
165
247
|
} else {
|
|
166
|
-
//
|
|
167
|
-
const displayName =
|
|
248
|
+
// Explicit opt-in: register a real account and persist it.
|
|
249
|
+
const displayName = identity.displayName;
|
|
168
250
|
const caps = ["claude-code", "coding", "chat"];
|
|
169
|
-
process.stderr.write(`[agentchat]
|
|
251
|
+
process.stderr.write(`[agentchat] Registering "${displayName}" with server...\n`);
|
|
170
252
|
try {
|
|
171
253
|
const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
|
|
172
254
|
method: "POST",
|
|
@@ -190,7 +272,11 @@ if (existsSync(profileFile)) {
|
|
|
190
272
|
profile = { agent_id: randomUUID(), display_name: displayName, token: "dev-token", capabilities: caps };
|
|
191
273
|
}
|
|
192
274
|
} catch (e) {
|
|
193
|
-
|
|
275
|
+
// Always print the cause. This catch used to report EVERY failure as "Server
|
|
276
|
+
// unreachable" — including the TDZ ReferenceError above, which silently disabled
|
|
277
|
+
// registration for a week while pointing operators at their network. A failure path
|
|
278
|
+
// that fabricates a plausible diagnosis is worse than one that says nothing.
|
|
279
|
+
process.stderr.write(`[agentchat] Registration failed: ${e} — using local profile\n`);
|
|
194
280
|
profile = { agent_id: randomUUID(), display_name: displayName, token: "dev-token", capabilities: caps };
|
|
195
281
|
}
|
|
196
282
|
mkdirSync(dirname(profileFile), { recursive: true });
|
|
@@ -198,8 +284,15 @@ if (existsSync(profileFile)) {
|
|
|
198
284
|
process.stderr.write(`[agentchat] Profile saved: ${profileFile}\n`);
|
|
199
285
|
}
|
|
200
286
|
|
|
201
|
-
//
|
|
202
|
-
|
|
287
|
+
// Second auto-register trigger: a loaded profile still carrying the `dev-token`
|
|
288
|
+
// placeholder. Healing it is intended for a declared identity, but on the bare
|
|
289
|
+
// shared default path it mints an anonymous account just like the first trigger.
|
|
290
|
+
if (profile.token === "dev-token" && !shouldMigrateDevToken({ source: profileSource, hasToken, registerFlag: !!cliArgs.register })) {
|
|
291
|
+
process.stderr.write(
|
|
292
|
+
`[agentchat] Profile at ${profileFile} carries a dev-token but no identity was declared — ` +
|
|
293
|
+
`refusing to auto-register. Pass --name <name> or --register to create a real agent.\n`,
|
|
294
|
+
);
|
|
295
|
+
} else if (profile.token === "dev-token") {
|
|
203
296
|
process.stderr.write(`[agentchat] Migrating dev-token profile — registering with server...\n`);
|
|
204
297
|
try {
|
|
205
298
|
const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
|
|
@@ -228,38 +321,17 @@ if (profile.token === "dev-token") {
|
|
|
228
321
|
process.stderr.write(`[agentchat] Migrated with new ID: ${data.id}\n`);
|
|
229
322
|
}
|
|
230
323
|
}
|
|
231
|
-
} catch {
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
let AGENT_ID = cliArgs.id || process.env.AGENTCHAT_AGENT_ID || profile.agent_id || randomUUID();
|
|
235
|
-
let TOKEN = cliArgs.token || process.env.AGENTCHAT_TOKEN || profile.token || "dev-token";
|
|
236
|
-
let CAPABILITIES: string[] = cliArgs.caps?.split(",") || profile.capabilities || ["claude-code", "coding", "chat"];
|
|
237
|
-
|
|
238
|
-
// Native fetch, captured before the file-wide call-site rename to apiFetch so the
|
|
239
|
-
// wrapper below can't recurse into itself.
|
|
240
|
-
const nativeFetch = fetch;
|
|
241
|
-
// All AgentsChat REST goes through apiFetch so every call gets, from one place:
|
|
242
|
-
// (1) a timeout — a hung hub call must never block a tool forever; and
|
|
243
|
-
// (2) the bearer token, injected only when absent and TOKEN is set (so the few
|
|
244
|
-
// conditional-auth sites keep their exact semantics). init is otherwise passed
|
|
245
|
-
// through untouched, so callers keep using r.ok / r.text() / r.json().
|
|
246
|
-
const REST_TIMEOUT_MS = 15_000;
|
|
247
|
-
async function apiFetch(
|
|
248
|
-
input: string | URL,
|
|
249
|
-
init: RequestInit = {},
|
|
250
|
-
timeoutMs = REST_TIMEOUT_MS,
|
|
251
|
-
): Promise<Response> {
|
|
252
|
-
const headers: Record<string, string> = { ...(init.headers as Record<string, string> | undefined) };
|
|
253
|
-
if (TOKEN && !("Authorization" in headers)) headers["Authorization"] = `Bearer ${TOKEN}`;
|
|
254
|
-
const controller = new AbortController();
|
|
255
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
256
|
-
try {
|
|
257
|
-
return await nativeFetch(input as any, { ...init, headers, signal: init.signal ?? controller.signal });
|
|
258
|
-
} finally {
|
|
259
|
-
clearTimeout(timer);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
// Was `catch {}`: it swallowed the same TDZ ReferenceError with no output at all.
|
|
326
|
+
process.stderr.write(`[agentchat] dev-token migration failed: ${e}\n`);
|
|
260
327
|
}
|
|
261
328
|
}
|
|
262
329
|
|
|
330
|
+
// Now that the identity block has settled `profile`, bind the runtime identity.
|
|
331
|
+
AGENT_ID = cliArgs.id || process.env.AGENTCHAT_AGENT_ID || profile.agent_id || randomUUID();
|
|
332
|
+
TOKEN = cliArgs.token || process.env.AGENTCHAT_TOKEN || profile.token || "dev-token";
|
|
333
|
+
CAPABILITIES = cliArgs.caps?.split(",") || profile.capabilities || ["claude-code", "coding", "chat"];
|
|
334
|
+
|
|
263
335
|
// Update display name if provided via CLI
|
|
264
336
|
if (cliArgs.name && profile.display_name !== cliArgs.name) {
|
|
265
337
|
profile.display_name = cliArgs.name;
|
|
@@ -1610,13 +1682,14 @@ function mimeFromPath(p: string): string {
|
|
|
1610
1682
|
}
|
|
1611
1683
|
// Upload a local file via multipart POST /api/upload → { url, mime, size }.
|
|
1612
1684
|
async function uploadLocalFile(path: string): Promise<{ url: string; mime: string; size: number }> {
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
const
|
|
1685
|
+
// node:fs (not Bun.file) so this runs on plain Node too — Bun supports node:fs
|
|
1686
|
+
// identically, and the Node path is what registry introspection (Glama) uses.
|
|
1687
|
+
if (!existsSync(path)) throw new Error(`file not found: ${path}`);
|
|
1688
|
+
const mime = mimeFromPath(path);
|
|
1689
|
+
const buf = readFileSync(path);
|
|
1617
1690
|
const name = path.split("/").pop() || "upload";
|
|
1618
1691
|
const form = new FormData();
|
|
1619
|
-
form.append("file", new Blob([
|
|
1692
|
+
form.append("file", new Blob([new Uint8Array(buf)], { type: mime }), name);
|
|
1620
1693
|
// No Content-Type header → fetch derives the multipart boundary itself.
|
|
1621
1694
|
const r = await apiFetch(`${REST_URL}/api/upload`, { method: "POST", headers: { "Authorization": `Bearer ${TOKEN}` }, body: form });
|
|
1622
1695
|
const text = await r.text();
|
|
@@ -1624,7 +1697,7 @@ async function uploadLocalFile(path: string): Promise<{ url: string; mime: strin
|
|
|
1624
1697
|
let data: any;
|
|
1625
1698
|
try { data = JSON.parse(text); } catch { throw new Error(`upload returned non-JSON: ${text.slice(0, 120)}`); }
|
|
1626
1699
|
if (!data?.url) throw new Error(`upload response missing url: ${text.slice(0, 120)}`);
|
|
1627
|
-
return { url: data.url as string, mime: (data.type as string) || mime, size: (data.size as number) ??
|
|
1700
|
+
return { url: data.url as string, mime: (data.type as string) || mime, size: (data.size as number) ?? buf.byteLength };
|
|
1628
1701
|
}
|
|
1629
1702
|
// Post a message carrying exactly one media attachment (image|audio).
|
|
1630
1703
|
async function sendMediaMessage(kind: "image" | "audio", args: any): Promise<{ content: any[]; isError?: boolean }> {
|
|
@@ -1981,15 +2054,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1981
2054
|
if (!meta) return { content: [{ type: "text", text: `sync_skill: personal skill "${a.name}" not found (save it with save_skill — no chat_id).` }], isError: true };
|
|
1982
2055
|
const currentVersion = Number(meta.version ?? 0);
|
|
1983
2056
|
let cachedVersion: number | null = null;
|
|
1984
|
-
try { cachedVersion = Number(JSON.parse(
|
|
2057
|
+
try { cachedVersion = Number(JSON.parse(readFileSync(pMeta, "utf8")).version); } catch {}
|
|
1985
2058
|
if (cachedVersion !== null && cachedVersion === currentVersion) {
|
|
1986
2059
|
return { content: [{ type: "text", text: `up-to-date: personal skill "${a.name}" v${currentVersion} already at ${pMd} — no download. Read that file to run it.` }] };
|
|
1987
2060
|
}
|
|
1988
2061
|
const bodyR = await apiFetch(`${REST_URL}/api/skills/${encodeURIComponent(a.name)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
|
|
1989
2062
|
if (!bodyR.ok) return { content: [{ type: "text", text: `sync_skill (personal): body fetch failed (${bodyR.status})` }], isError: true };
|
|
1990
2063
|
const doc = JSON.parse(await bodyR.text());
|
|
1991
|
-
|
|
1992
|
-
|
|
2064
|
+
// node:fs (not Bun.write) — runs on plain Node too. Bun.write auto-creates
|
|
2065
|
+
// parent dirs; writeFileSync does not, so mkdir the cache dir first.
|
|
2066
|
+
mkdirSync(dirname(pMd), { recursive: true });
|
|
2067
|
+
writeFileSync(pMd, String(doc?.body_markdown ?? ""));
|
|
2068
|
+
writeFileSync(pMeta, JSON.stringify({ version: currentVersion, name: a.name, syncedAt: new Date().toISOString() }));
|
|
1993
2069
|
return { content: [{ type: "text", text: `synced personal skill "${a.name}" v${currentVersion} → ${pMd} (was ${cachedVersion === null ? "missing" : `stale v${cachedVersion}`}). Read that file to run it.` }] };
|
|
1994
2070
|
} catch (e: any) {
|
|
1995
2071
|
return { content: [{ type: "text", text: `sync_skill (personal) error: ${String(e?.message || e).slice(0, 140)}` }], isError: true };
|
|
@@ -2013,7 +2089,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2013
2089
|
const currentVersion = Number(meta.version ?? 0);
|
|
2014
2090
|
// 2. Local cache check — skip the body fetch if we already have this version.
|
|
2015
2091
|
let cachedVersion: number | null = null;
|
|
2016
|
-
try { cachedVersion = Number(JSON.parse(
|
|
2092
|
+
try { cachedVersion = Number(JSON.parse(readFileSync(metaPath, "utf8")).version); } catch {}
|
|
2017
2093
|
if (cachedVersion !== null && cachedVersion === currentVersion) {
|
|
2018
2094
|
return { content: [{ type: "text", text: `up-to-date: "${a.doc_id}" v${currentVersion} already at ${mdPath} — no download. Read that file to run it.` }] };
|
|
2019
2095
|
}
|
|
@@ -2022,9 +2098,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2022
2098
|
if (!docR.ok) return { content: [{ type: "text", text: `sync_skill: fetch body failed (${docR.status})` }], isError: true };
|
|
2023
2099
|
const doc = JSON.parse(await docR.text());
|
|
2024
2100
|
const body = String(doc?.body_markdown ?? doc?.bodyMarkdown ?? "");
|
|
2025
|
-
// 4. Write the local mirror + version sidecar.
|
|
2026
|
-
|
|
2027
|
-
|
|
2101
|
+
// 4. Write the local mirror + version sidecar (node:fs, not Bun.write, for
|
|
2102
|
+
// Node compat; mkdir the cache dir first since writeFileSync won't auto-create).
|
|
2103
|
+
mkdirSync(dirname(mdPath), { recursive: true });
|
|
2104
|
+
writeFileSync(mdPath, body);
|
|
2105
|
+
writeFileSync(metaPath, JSON.stringify({ version: currentVersion, title: doc?.title, doc_id: a.doc_id, chat_id: a.chat_id, syncedAt: new Date().toISOString() }));
|
|
2028
2106
|
const was = cachedVersion === null ? "missing" : `stale v${cachedVersion}`;
|
|
2029
2107
|
return { content: [{ type: "text", text: `synced "${doc?.title || a.doc_id}" v${currentVersion} → ${mdPath} (was ${was}). Read that file to run it in your runtime.` }] };
|
|
2030
2108
|
} catch (e: any) {
|
|
@@ -2710,7 +2788,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2710
2788
|
} catch (e: any) {
|
|
2711
2789
|
authLine = `REST auth: error (${String(e?.message || e).slice(0, 80)})`;
|
|
2712
2790
|
}
|
|
2713
|
-
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: ${
|
|
2791
|
+
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)")}` }] };
|
|
2714
2792
|
}
|
|
2715
2793
|
|
|
2716
2794
|
if (name === "list_channels") {
|
|
@@ -2903,11 +2981,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2903
2981
|
}
|
|
2904
2982
|
sessionId = null;
|
|
2905
2983
|
|
|
2906
|
-
// Update identity
|
|
2984
|
+
// Update identity. activeProfileFile must move too — whoami reports it, and a
|
|
2985
|
+
// stale value there makes the identity probe lie about which profile is live.
|
|
2907
2986
|
AGENT_ID = newProfile.agent_id;
|
|
2908
2987
|
TOKEN = newProfile.token || "dev-token";
|
|
2909
2988
|
CAPABILITIES = newProfile.capabilities || ["claude-code", "coding", "chat"];
|
|
2910
2989
|
profile = newProfile;
|
|
2990
|
+
activeProfileFile = targetFile;
|
|
2991
|
+
anonymousMode = false;
|
|
2911
2992
|
|
|
2912
2993
|
// Restart heartbeat and connect with new identity
|
|
2913
2994
|
wsReconnectAttempt = 0;
|
|
@@ -3282,15 +3363,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3282
3363
|
// Persisted to disk so reconnect/restart doesn't lose state
|
|
3283
3364
|
const mentionTsFile = join(configDir, `mention-ts-${AGENT_ID}.json`);
|
|
3284
3365
|
function loadMentionTimestamps(): Map<string, string> {
|
|
3285
|
-
|
|
3286
|
-
const raw = readFileSync(mentionTsFile, "utf-8");
|
|
3287
|
-
return new Map(Object.entries(JSON.parse(raw)));
|
|
3288
|
-
} catch { return new Map(); }
|
|
3366
|
+
return loadCursor(mentionTsFile, safeStderrWrite);
|
|
3289
3367
|
}
|
|
3368
|
+
// Re-attempted on the next mention, but a persistent failure (perms, full disk) would
|
|
3369
|
+
// retry just as silently forever — and the state is gone on restart. So it reports.
|
|
3290
3370
|
function saveMentionTimestamps(m: Map<string, string>) {
|
|
3291
|
-
|
|
3292
|
-
writeFileSync(mentionTsFile, JSON.stringify(Object.fromEntries(m)));
|
|
3293
|
-
} catch {}
|
|
3371
|
+
persistCursor(mentionTsFile, m, safeStderrWrite);
|
|
3294
3372
|
}
|
|
3295
3373
|
const lastMentionTimestamp = loadMentionTimestamps();
|
|
3296
3374
|
|
|
@@ -3311,36 +3389,29 @@ const lastMentionTimestamp = loadMentionTimestamps();
|
|
|
3311
3389
|
// connect or reconnect; if we missed anything, we find it.
|
|
3312
3390
|
const lastSeenMessageTsFile = join(configDir, `last-seen-msg-ts-${AGENT_ID}.json`);
|
|
3313
3391
|
function loadLastSeenMessageTs(): Map<string, string> {
|
|
3314
|
-
|
|
3315
|
-
const raw = readFileSync(lastSeenMessageTsFile, "utf-8");
|
|
3316
|
-
return new Map(Object.entries(JSON.parse(raw)));
|
|
3317
|
-
} catch { return new Map(); }
|
|
3318
|
-
}
|
|
3319
|
-
function saveLastSeenMessageTs(m: Map<string, string>) {
|
|
3320
|
-
try {
|
|
3321
|
-
writeFileSync(lastSeenMessageTsFile, JSON.stringify(Object.fromEntries(m)));
|
|
3322
|
-
} catch {}
|
|
3392
|
+
return loadCursor(lastSeenMessageTsFile, safeStderrWrite);
|
|
3323
3393
|
}
|
|
3324
3394
|
const lastSeenMessageTs = loadLastSeenMessageTs();
|
|
3325
3395
|
const cursorFlushIntervalMs = Math.max(
|
|
3326
3396
|
500,
|
|
3327
3397
|
Number(process.env.AGENTSCHAT_MCP_CURSOR_FLUSH_MS || 5000),
|
|
3328
3398
|
);
|
|
3329
|
-
|
|
3399
|
+
/** Dirty flag lives in an object so flushCursor can clear it only on a landed write. */
|
|
3400
|
+
const cursorState = { dirty: false };
|
|
3330
3401
|
let lastSeenMessageTsTimer: ReturnType<typeof setTimeout> | null = null;
|
|
3331
3402
|
|
|
3332
3403
|
function flushLastSeenMessageTs() {
|
|
3333
|
-
if (!
|
|
3334
|
-
lastSeenMessageTsDirty = false;
|
|
3404
|
+
if (!cursorState.dirty) return;
|
|
3335
3405
|
if (lastSeenMessageTsTimer) {
|
|
3336
3406
|
clearTimeout(lastSeenMessageTsTimer);
|
|
3337
3407
|
lastSeenMessageTsTimer = null;
|
|
3338
3408
|
}
|
|
3339
|
-
|
|
3409
|
+
// Stays dirty if the write fails, so the shutdown fallback flush actually retries.
|
|
3410
|
+
flushCursor(cursorState, () => persistCursor(lastSeenMessageTsFile, lastSeenMessageTs, safeStderrWrite));
|
|
3340
3411
|
}
|
|
3341
3412
|
|
|
3342
3413
|
function scheduleLastSeenMessageTsSave() {
|
|
3343
|
-
|
|
3414
|
+
cursorState.dirty = true;
|
|
3344
3415
|
if (lastSeenMessageTsTimer) return;
|
|
3345
3416
|
lastSeenMessageTsTimer = setTimeout(() => {
|
|
3346
3417
|
lastSeenMessageTsTimer = null;
|
|
@@ -3930,7 +4001,8 @@ function shutdownFromStdio(reason: string) {
|
|
|
3930
4001
|
if (shuttingDown) return;
|
|
3931
4002
|
shuttingDown = true;
|
|
3932
4003
|
safeStderrWrite(`[agentchat] Stdio closed (${reason}), shutting down\n`);
|
|
3933
|
-
|
|
4004
|
+
// Persisting the read cursor is a state change, not teardown: report, never swallow.
|
|
4005
|
+
try { flushLastSeenMessageTs(); } catch (e) { safeStderrWrite(`[agentchat] WARNING: read-cursor flush failed on shutdown: ${e}\n`); }
|
|
3934
4006
|
try { heartbeat.stop(); } catch {}
|
|
3935
4007
|
try { stopAllTypingHeartbeats(); } catch {}
|
|
3936
4008
|
if (reconnectTimer) {
|
|
@@ -3967,7 +4039,7 @@ function installStdioLifecycleGuards() {
|
|
|
3967
4039
|
process.stderr.on("error", handleOutputError);
|
|
3968
4040
|
process.on("SIGPIPE", () => shutdownFromStdio("SIGPIPE"));
|
|
3969
4041
|
process.on("beforeExit", () => {
|
|
3970
|
-
try { flushLastSeenMessageTs(); } catch {}
|
|
4042
|
+
try { flushLastSeenMessageTs(); } catch (e) { safeStderrWrite(`[agentchat] WARNING: read-cursor fallback flush failed: ${e}\n`); }
|
|
3971
4043
|
});
|
|
3972
4044
|
}
|
|
3973
4045
|
|
|
@@ -4000,7 +4072,13 @@ async function checkVersionStaleness(): Promise<void> {
|
|
|
4000
4072
|
// --- Start ---
|
|
4001
4073
|
async function main() {
|
|
4002
4074
|
installStdioLifecycleGuards();
|
|
4003
|
-
|
|
4075
|
+
// Anonymous = no credentials to present. Connecting would just fail auth in a
|
|
4076
|
+
// reconnect loop; stdio (initialize/tools/list) is served either way.
|
|
4077
|
+
if (anonymousMode) {
|
|
4078
|
+
process.stderr.write(`[agentchat] Anonymous — not connecting to the hub.\n`);
|
|
4079
|
+
} else {
|
|
4080
|
+
connectWS();
|
|
4081
|
+
}
|
|
4004
4082
|
|
|
4005
4083
|
// Stdio is the only supported transport. The --port HTTP SSE path was
|
|
4006
4084
|
// removed in v0.6.7 — OpenClaw users should install the native channel
|