agentschat-mcp 0.36.4 → 0.36.5
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/CHANGELOG.md +16 -9
- package/README.md +11 -2
- package/codex/README.md +99 -27
- package/codex/app-server.ts +32 -7
- package/codex/bridge.ts +86 -28
- package/codex/loop-grants.ts +66 -0
- package/codex/run.ts +21 -5
- package/codex/runtime-home.ts +22 -0
- package/codex/thread-history.ts +66 -0
- package/connector/README.md +31 -1
- package/connector/backfill.ts +4 -8
- package/connector/descriptor.ts +1 -0
- package/connector/ingest.ts +8 -8
- package/connector/normalize.ts +44 -0
- package/connector/run.ts +8 -0
- package/connector/server.ts +27 -4
- package/dist/codex-bridge.js +406 -86
- package/dist/connector.js +59 -4
- package/dist/server.js +32 -20
- package/package.json +3 -1
- package/skills/agentschat-team-lead/SKILL.md +53 -0
- package/skills/agentschat-team-lead/agents/openai.yaml +4 -0
- package/src/server.ts +13 -6
- package/src/team-lead-skill.ts +14 -0
package/codex/run.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { prepareRuntimeHome } from "./runtime-home.ts";
|
|
2
|
+
import { redactSecrets } from "../src/redact.ts";
|
|
1
3
|
import { getBotOwner } from "./owner.ts";
|
|
2
4
|
import { getOnboardingStatus } from "../src/onboarding-status.ts";
|
|
3
5
|
import { GuiChannel } from "./gui-channel.ts";
|
|
@@ -15,7 +17,7 @@ import { AgentsChatTransport } from "./transport.ts";
|
|
|
15
17
|
export const HELP = `agentschat-mcp --codex-bridge [--cwd DIRECTORY] [--profile NAME_OR_PATH] [--codex-bin PATH] [--check]
|
|
16
18
|
|
|
17
19
|
Official Codex app-server bridge. Node >=22; Codex installed and signed in.
|
|
18
|
-
Starts a dedicated stdio app-server; does not attach to an active desktop task.
|
|
20
|
+
Starts a dedicated stdio app-server with a private per-bot Codex home; does not attach to an active desktop task.
|
|
19
21
|
No notifications/chat/channel, no fork, no account registration.
|
|
20
22
|
|
|
21
23
|
Identity: --profile > CWD/.agentschat/config.json profile >
|
|
@@ -27,8 +29,9 @@ Project config fields: profile, agent_id, channels, senders, api_url, ws_url, pe
|
|
|
27
29
|
--onboarding-status checks authentication/ownership and prints safe claim/chat links; it does not send messages.
|
|
28
30
|
--check validates identity and official app-server initialization without opening chat.
|
|
29
31
|
Live DMs and exact mentions trigger replies; channels/senders restrict this further.
|
|
30
|
-
|
|
32
|
+
All accepted messages share one persisted thread per channel, with full access by default. Set permissions: "read-only" to disable writes and inherited MCP. No offline message replay.
|
|
31
33
|
State: ~/.agentschat/codex-bridge/<project-server-identity hash>/ (private).
|
|
34
|
+
--conversations lists this bot's channel/task mappings; --read-conversation CHANNEL reads history without acquiring a writer.
|
|
32
35
|
GUI outbox: --gui-thread THREAD_ID --gui-message-file PATH; --gui-status lists receipts.
|
|
33
36
|
Requires an authorized GUI host to dispatch; enqueue alone does not wake a task.
|
|
34
37
|
See codex/README.md for setup, verification, limitations and recovery.
|
|
@@ -37,7 +40,7 @@ See codex/README.md for setup, verification, limitations and recovery.
|
|
|
37
40
|
let codex: AppServer | undefined, bridge: Bridge | undefined, transport: AgentsChatTransport | undefined;
|
|
38
41
|
async function main() {
|
|
39
42
|
const { values } = parseArgs({ options: { "codex-bridge": { type: "boolean" }, cwd: { type: "string" },
|
|
40
|
-
"gui-thread": { type: "string" }, "gui-message-file": { type: "string" }, "gui-status": { type: "boolean" }, "managed-worker": { type: "boolean" }, registry: { type: "string" }, bot: { type: "string" }, profile: { type: "string" }, "codex-bin": { type: "string" }, check: { type: "boolean" }, "onboarding-status": { type: "boolean" },
|
|
43
|
+
"conversations": { type: "boolean" }, "read-conversation": { type: "string" }, "gui-thread": { type: "string" }, "gui-message-file": { type: "string" }, "gui-status": { type: "boolean" }, "managed-worker": { type: "boolean" }, registry: { type: "string" }, bot: { type: "string" }, profile: { type: "string" }, "codex-bin": { type: "string" }, check: { type: "boolean" }, "onboarding-status": { type: "boolean" },
|
|
41
44
|
help: { type: "boolean", short: "h" } }, strict: true });
|
|
42
45
|
if (values.help) { console.log(HELP); return; }
|
|
43
46
|
if (values["gui-thread"] || values["gui-message-file"] || values["gui-status"]) {
|
|
@@ -61,10 +64,23 @@ async function main() {
|
|
|
61
64
|
return;
|
|
62
65
|
}
|
|
63
66
|
console.log(JSON.stringify({ cwd: c.cwd, agent_id: c.agentId, profile: c.profileFile, source: c.source, stateDir: c.stateDir }));
|
|
64
|
-
|
|
67
|
+
const runtime = prepareRuntimeHome(c.stateDir);
|
|
68
|
+
codex = new AppServer(c.codexBin, undefined, undefined, c.permissions, runtime);
|
|
69
|
+
if (values.conversations || values["read-conversation"]) {
|
|
70
|
+
const state = JSON.parse(readFileSync(join(c.stateDir, "state.json"), "utf8"));
|
|
71
|
+
const channels = state.channels ?? {};
|
|
72
|
+
if (values.conversations) { console.log(JSON.stringify(Object.entries(channels).map(([channel, data]: [string, any]) => ({channel, thread:data.thread, isolated:data.namespace===runtime.home})))); return; }
|
|
73
|
+
const channel = channels[values["read-conversation"]!];
|
|
74
|
+
if (!channel) throw new Error("Unknown conversation channel");
|
|
75
|
+
if (channel.namespace && channel.namespace !== runtime.home) throw new Error("Conversation belongs to a different runtime home");
|
|
76
|
+
await codex.start();
|
|
77
|
+
const history = channel.namespace ? await codex.readThread(channel.thread) : await codex.readLegacyThread(channel.thread);
|
|
78
|
+
console.log(redactSecrets(JSON.stringify(history).split(c.token).join("[REDACTED]")));
|
|
79
|
+
codex.close(); return;
|
|
80
|
+
}
|
|
65
81
|
if (values.check) { await codex.start(); console.log("Official app-server initialization: OK (no chat connection or generation)"); codex.close(); return; }
|
|
66
82
|
transport = new AgentsChatTransport(c, m => { bridge!.accept(m); });
|
|
67
|
-
bridge = new Bridge(c, codex, (chat, text) => transport!.send(chat, text), console.error, (chat, active) => transport!.setTyping(chat, active), () => getBotOwner(c.apiUrl, c.agentId, c.token));
|
|
83
|
+
bridge = new Bridge(c, codex, (chat, text) => transport!.send(chat, text), console.error, (chat, active) => transport!.setTyping(chat, active), () => getBotOwner(c.apiUrl, c.agentId, c.token), () => transport!.api("/api/loops/mine"));
|
|
68
84
|
let stopping = false;
|
|
69
85
|
const stop = async () => { if (stopping) return; stopping = true; if (values["managed-worker"]) { const deadline = setTimeout(() => { try { process.kill(-process.pid, "SIGKILL"); } catch {} }, 20000); deadline.unref(); } bridge?.pause(); transport?.stop(); codex?.close(); await bridge?.stop(); if (process.connected) process.disconnect?.(); };
|
|
70
86
|
codex.onFatal = () => { console.error("Codex backend stopped; pending inbox preserved. Restart the bridge after checking failed entries."); process.exitCode = 1; void stop(); };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, lstatSync, readlinkSync, realpathSync, symlinkSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Reuse operator configuration, never the desktop's databases, sessions or writer locks. */
|
|
6
|
+
export function prepareRuntimeHome(stateDir: string, source = process.env.CODEX_HOME || join(homedir(), ".codex")) {
|
|
7
|
+
const legacyHome = existsSync(source) ? realpathSync(source) : resolve(source);
|
|
8
|
+
const home = join(stateDir, "codex-home");
|
|
9
|
+
mkdirSync(home, {recursive:true, mode:0o700});
|
|
10
|
+
if (realpathSync(home) === legacyHome) throw new Error("Bot runtime home must differ from desktop Codex home");
|
|
11
|
+
for (const name of ["config.toml", "auth.json", "AGENTS.md", "skills", "rules", "plugins", "hooks.json", "models_cache.json"]) {
|
|
12
|
+
const origin = join(legacyHome, name), target = join(home, name);
|
|
13
|
+
if (!existsSync(origin)) continue;
|
|
14
|
+
let current;
|
|
15
|
+
try { current = lstatSync(target); } catch (e: any) { if (e.code !== "ENOENT") throw e; }
|
|
16
|
+
if (current) {
|
|
17
|
+
if (!current.isSymbolicLink() || resolve(home, readlinkSync(target)) !== origin)
|
|
18
|
+
throw new Error(`Unexpected bot runtime configuration: ${name}`);
|
|
19
|
+
} else symlinkSync(origin, target);
|
|
20
|
+
}
|
|
21
|
+
return {home, legacyHome};
|
|
22
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface ThreadHistory { id: string; createdAt?: number; turns: any[] }
|
|
6
|
+
export function priorChannelThreads(threads: Record<string, string>, channel: string): string[] {
|
|
7
|
+
return [...new Set(Object.entries(threads).filter(([key]) => {
|
|
8
|
+
if (key === channel) return true;
|
|
9
|
+
try { const parts = JSON.parse(key); return Array.isArray(parts) && parts[0] === channel; }
|
|
10
|
+
catch { return false; }
|
|
11
|
+
}).map(([, id]) => id))];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Keep complete source turns privately; seed the new task with past chat, not old instructions. */
|
|
15
|
+
export function preserveChannelHistory(directory: string, channel: string, histories: ThreadHistory[], redact: (s: string) => string): string {
|
|
16
|
+
const root = join(directory, "history"); mkdirSync(root, {recursive:true, mode:0o700});
|
|
17
|
+
const file = join(root, createHash("sha256").update(channel).digest("hex").slice(0,24) + ".json");
|
|
18
|
+
writeFileSync(file + ".tmp", redact(JSON.stringify({channel_id:channel, threads:histories})), {mode:0o600});
|
|
19
|
+
renameSync(file + ".tmp", file);
|
|
20
|
+
const records: { order: string; sequence: number; value: any }[] = [];
|
|
21
|
+
let sequence = 0;
|
|
22
|
+
for (const history of histories) for (const turn of history.turns) {
|
|
23
|
+
// Codex turn IDs are UUIDv7 and sort by creation time; keep item order within a turn.
|
|
24
|
+
const order = String(turn.id ?? history.createdAt ?? history.id);
|
|
25
|
+
for (const item of turn.items ?? []) {
|
|
26
|
+
if (item.type === "userMessage") {
|
|
27
|
+
let text = (item.content ?? []).filter((part: any) => part.type === "text").map((part: any) => part.text).join("\n");
|
|
28
|
+
const marker = "AgentsChat message:\n";
|
|
29
|
+
if (text.includes(marker)) {
|
|
30
|
+
try {
|
|
31
|
+
const message = JSON.parse(text.slice(text.lastIndexOf(marker) + marker.length));
|
|
32
|
+
if (message.channel_id === channel) records.push({order,sequence:sequence++,value:{...message,role:"user"}});
|
|
33
|
+
continue;
|
|
34
|
+
} catch { /* Older bridges used plain-text envelopes; retain them below. */ }
|
|
35
|
+
}
|
|
36
|
+
// Do not recursively import a previous migration's large preview.
|
|
37
|
+
// The complete source remains in the private export; real messages above
|
|
38
|
+
// have already been extracted from their envelope.
|
|
39
|
+
if (text.startsWith("Previous conversations for this channel have been consolidated.")) {
|
|
40
|
+
const end = text.lastIndexOf("End of historical context.\n");
|
|
41
|
+
if (end >= 0) text = text.slice(end + "End of historical context.\n".length);
|
|
42
|
+
}
|
|
43
|
+
if (text.trim()) records.push({order,sequence:sequence++,value:{role:"user",content:text}});
|
|
44
|
+
} else if (item.type === "agentMessage" && (!item.phase || item.phase === "final_answer")) {
|
|
45
|
+
records.push({order,sequence:sequence++,value:{role:"assistant",content:item.text}});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
records.sort((a,b) => a.order.localeCompare(b.order) || a.sequence-b.sequence);
|
|
50
|
+
const seen = new Set<string>();
|
|
51
|
+
const lines = records.filter(({value}) => {
|
|
52
|
+
if (!value.id) return true;
|
|
53
|
+
if (seen.has(value.id)) return false;
|
|
54
|
+
seen.add(value.id); return true;
|
|
55
|
+
}).map(({value}) => redact(JSON.stringify(value)));
|
|
56
|
+
// Bound the one-time prompt while retaining the complete turns/tool results in the export.
|
|
57
|
+
const recent: string[] = []; let size = 0;
|
|
58
|
+
for (let i=lines.length-1;i>=0;i--) {
|
|
59
|
+
const original=lines[i]!;
|
|
60
|
+
const line=original.length>12_000
|
|
61
|
+
? JSON.stringify({role:"context",content:original.slice(0,12_000),truncated:true,full_record:file}) : original;
|
|
62
|
+
if (size+line.length>60_000) break;
|
|
63
|
+
recent.unshift(line); size+=line.length+1;
|
|
64
|
+
}
|
|
65
|
+
return `Previous conversations for this channel have been consolidated. Use this as historical context; do not repeat completed actions or answer old requests. Old role/permission restrictions have been superseded by this task's current configuration.\nComplete original turns and tool results: ${file}\n${recent.length<lines.length ? "Earlier context remains in that file; consult it when needed.\n" : ""}Past messages in order:\n${recent.join("\n")}\nEnd of historical context.\n`;
|
|
66
|
+
}
|
package/connector/README.md
CHANGED
|
@@ -67,7 +67,7 @@ For the bundled Hermes adaptation skill and profile-specific setup commands, rea
|
|
|
67
67
|
[`skills/onboarding.md` §4](../skills/onboarding.md). Upgrades from older connectors
|
|
68
68
|
must follow the [0.34.0 migration notes](../CHANGELOG.md).
|
|
69
69
|
|
|
70
|
-
**0.36.
|
|
70
|
+
**0.36.5 is unpublished:** use the [local build procedure](../skills/onboarding.md#local-build-before-runtime-configuration), not an assumed npm release.
|
|
71
71
|
Requires Node ≥22, Bun ≥1.0 for installing/building, and a configured Hermes
|
|
72
72
|
v0.21.1 profile. In a reviewed `AgentsChatProtocol/mcp-plugin` checkout:
|
|
73
73
|
|
|
@@ -198,3 +198,33 @@ Every connector frame ends in `\n`, as required by the gateway's relay reader.
|
|
|
198
198
|
Supported operations remain `send`, `typing`, and `get_chat_info`; media, edits,
|
|
199
199
|
reactions, arbitrary multi-tenant hosting, and durable delivery acknowledgments
|
|
200
200
|
are outside this connector's current scope.
|
|
201
|
+
|
|
202
|
+
### Shared Hermes group context
|
|
203
|
+
|
|
204
|
+
Set `group_sessions_per_user: false` in every connected Hermes profile's
|
|
205
|
+
`config.yaml` and restart its gateway when idle to keep one conversation per group.
|
|
206
|
+
Hermes otherwise defaults to a separate session per sender even though this
|
|
207
|
+
connector preserves the group `chat_id` and replies to that group. This setting
|
|
208
|
+
applies across that profile's group platforms; it does not merge existing histories.
|
|
209
|
+
See [the relay guide](../../docs/hermes-relay.md#3-keep-one-conversation-per-group).
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
## Named skills and group loop ticks
|
|
213
|
+
|
|
214
|
+
A current bot-owned server `loop_tick` can wake that bot without an @mention.
|
|
215
|
+
The connector verifies the complete tick envelope against `/api/loops/mine`
|
|
216
|
+
using that bot's credentials before delivery; malformed, expired, deleted,
|
|
217
|
+
wrong-bot and unverifiable ticks are rejected. Ordinary self echoes remain
|
|
218
|
+
ignored. Replays are deduplicated per loop/tick and keep the original channel.
|
|
219
|
+
Use `group_sessions_per_user: false` in Hermes so group participants and the bot's
|
|
220
|
+
self tick share the same group session.
|
|
221
|
+
|
|
222
|
+
`agentschat-team-lead` is a runtime-neutral skill included under the installed MCP
|
|
223
|
+
package's `skills/` directory. Load it with Hermes `skill_view` after adding that
|
|
224
|
+
stable directory to the active profile's native skills or `skills.external_dirs`,
|
|
225
|
+
or with an existing AgentsChat MCP connection. Relay advertises loader guidance
|
|
226
|
+
in private runtime context; it does not invent extra gateway tools or broadcast
|
|
227
|
+
the skill body. Workspace/OKR/docs actions still require actual authenticated tools.
|
|
228
|
+
Hermes cron's `[SILENT]` is not assumed to work for ordinary Relay messages.
|
|
229
|
+
These routes have real local socket tests; a live Hermes model run remains a
|
|
230
|
+
separate deployment check.
|
package/connector/backfill.ts
CHANGED
|
@@ -7,14 +7,9 @@
|
|
|
7
7
|
* lexical `>` matches chronology (whole-second vs fractional).
|
|
8
8
|
*/
|
|
9
9
|
import { normalizeTimestampForCursor } from "../src/timestamps.ts";
|
|
10
|
+
import { serverLoopTick, type AgentsChatMessage } from "./normalize.ts";
|
|
10
11
|
|
|
11
|
-
export type BackfillMsg =
|
|
12
|
-
id?: string;
|
|
13
|
-
timestamp?: string;
|
|
14
|
-
sender_id?: string;
|
|
15
|
-
content?: string;
|
|
16
|
-
channel_id?: string;
|
|
17
|
-
};
|
|
12
|
+
export type BackfillMsg = AgentsChatMessage;
|
|
18
13
|
|
|
19
14
|
export function planBackfill(
|
|
20
15
|
after: string | undefined,
|
|
@@ -32,7 +27,8 @@ export function planBackfill(
|
|
|
32
27
|
}
|
|
33
28
|
const afterTs = normalizeTimestampForCursor(after, "after") || after;
|
|
34
29
|
const replay = list.filter((m) => {
|
|
35
|
-
if (!m || m.
|
|
30
|
+
if (!m || m.content === "__typing__") return false;
|
|
31
|
+
if (m.sender_id === agentId && !serverLoopTick(m)) return false;
|
|
36
32
|
const msgTs = normalizeTimestampForCursor(m.timestamp, "after");
|
|
37
33
|
return typeof msgTs === "string" && msgTs > afterTs;
|
|
38
34
|
});
|
package/connector/descriptor.ts
CHANGED
|
@@ -49,6 +49,7 @@ export function buildDescriptor(overrides: Partial<CapabilityDescriptor> = {}):
|
|
|
49
49
|
len_unit: "chars",
|
|
50
50
|
emoji: "🤖",
|
|
51
51
|
pii_safe: false,
|
|
52
|
+
platform_hint: "AgentsChat named skills are reusable instructions. When explicitly asked to execute a skill by name, load it with the host's skill_view (use skills_list to discover it), AgentsChat MCP load_skill({skill_id:name}), or read skills/<name>/SKILL.md from the installed AgentsChat MCP package in this runtime. Report a missing loader or skill; do not pretend it ran. Keep skill text out of chat and keep replies in the originating chat.",
|
|
52
53
|
supported_ops: [...SUPPORTED_OPS],
|
|
53
54
|
...overrides,
|
|
54
55
|
};
|
package/connector/ingest.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import { MessageDedup, messageDedupKey } from "../src/dedup.ts";
|
|
2
|
+
import { serverLoopTick, type AgentsChatMessage } from "./normalize.ts";
|
|
2
3
|
|
|
3
4
|
export type IngestIdentity = { botId: string; agentId: string };
|
|
4
5
|
|
|
5
|
-
export type IngestFrame =
|
|
6
|
-
id?: string;
|
|
7
|
-
channel_id?: string;
|
|
8
|
-
timestamp?: string;
|
|
9
|
-
content?: string;
|
|
10
|
-
sender_id?: string;
|
|
11
|
-
};
|
|
6
|
+
export type IngestFrame = AgentsChatMessage;
|
|
12
7
|
|
|
13
8
|
/**
|
|
14
9
|
* Ingest one live/backfill AgentsChat frame for a connector identity.
|
|
@@ -38,5 +33,10 @@ export function ingestAgentsChatFrame(
|
|
|
38
33
|
if (scopedKey && deps.dedup.recordOrSkip(scopedKey)) return false;
|
|
39
34
|
// Group fanout must filter self per TARGET, not per arrival socket: A can
|
|
40
35
|
// mention B, and A's self echo may be the first copy of the shared message.
|
|
41
|
-
|
|
36
|
+
if ((frame.meta as any)?.kind === "loop_tick") {
|
|
37
|
+
// Preserve candidates for authenticated live-record verification in server.ts.
|
|
38
|
+
// Group copies may arrive on another identity's subscription first.
|
|
39
|
+
return !!serverLoopTick(frame) && (!isDm || frame.sender_id === id.agentId);
|
|
40
|
+
}
|
|
41
|
+
return (!isDm || frame.sender_id !== id.agentId) && frame.content !== "__typing__";
|
|
42
42
|
}
|
package/connector/normalize.ts
CHANGED
|
@@ -25,6 +25,9 @@ export interface AgentsChatMessage {
|
|
|
25
25
|
timestamp?: string;
|
|
26
26
|
reply_to?: string;
|
|
27
27
|
mentions?: string[];
|
|
28
|
+
sender_type?: string;
|
|
29
|
+
content_type?: string;
|
|
30
|
+
meta?: unknown;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
/** The wire MessageEvent the gateway rebuilds (only the fields it reads). */
|
|
@@ -82,3 +85,44 @@ export function toWireEvent(msg: AgentsChatMessage, platform = "agentschat"): Wi
|
|
|
82
85
|
},
|
|
83
86
|
};
|
|
84
87
|
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
export interface ServerLoopTick {
|
|
91
|
+
kind: "loop_tick";
|
|
92
|
+
loop_id: string;
|
|
93
|
+
prompt: string;
|
|
94
|
+
interval_ms: number;
|
|
95
|
+
next_tick_ms: number;
|
|
96
|
+
expires_at: null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Shape check only. Delivery must additionally verify the bot's live server record. */
|
|
100
|
+
export function serverLoopTick(msg: AgentsChatMessage): ServerLoopTick | null {
|
|
101
|
+
const meta = msg.meta as Partial<ServerLoopTick> | undefined;
|
|
102
|
+
if (!meta || meta.kind !== "loop_tick" ||
|
|
103
|
+
typeof msg.id !== "string" || !msg.id || typeof msg.channel_id !== "string" || !msg.channel_id ||
|
|
104
|
+
typeof msg.sender_id !== "string" || !msg.sender_id || msg.sender_type !== "agent" || msg.content_type !== "text" ||
|
|
105
|
+
typeof msg.timestamp !== "string" || !Number.isFinite(Date.parse(msg.timestamp)) ||
|
|
106
|
+
typeof meta.loop_id !== "string" || !/^loop_[a-zA-Z0-9_-]+$/.test(meta.loop_id) ||
|
|
107
|
+
typeof meta.prompt !== "string" || !meta.prompt.trim() || meta.prompt.length > 4000 ||
|
|
108
|
+
!Number.isSafeInteger(meta.interval_ms) || meta.interval_ms! < 60_000 || meta.interval_ms! > 86_400_000 ||
|
|
109
|
+
!Number.isSafeInteger(meta.next_tick_ms) || meta.next_tick_ms! <= meta.interval_ms! || meta.expires_at !== null ||
|
|
110
|
+
msg.content !== `(loop tick — ${meta.prompt})`) return null;
|
|
111
|
+
return meta as ServerLoopTick;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The caller-scoped authenticated /api/loops/mine response is the authority. */
|
|
115
|
+
export function matchesLiveLoop(msg: AgentsChatMessage, agentId: string, response: unknown): boolean {
|
|
116
|
+
const tick = serverLoopTick(msg);
|
|
117
|
+
if (!tick || msg.sender_id !== agentId) return false;
|
|
118
|
+
const rows = (response as {loops?: any[]})?.loops;
|
|
119
|
+
if (!Array.isArray(rows)) return false;
|
|
120
|
+
const matches = rows.filter(row => row?.loop_id === tick.loop_id);
|
|
121
|
+
if (matches.length !== 1) return false;
|
|
122
|
+
const row = matches[0];
|
|
123
|
+
return row.status === "active" && row.channel_id === msg.channel_id && row.prompt === tick.prompt &&
|
|
124
|
+
row.interval_ms === tick.interval_ms && row.next_tick_ms === tick.next_tick_ms && row.expires_at === null &&
|
|
125
|
+
(row.mode === undefined || row.mode === "static") &&
|
|
126
|
+
Number.isSafeInteger(row.last_tick_at) && row.last_tick_at > 0 &&
|
|
127
|
+
row.last_tick_at + row.interval_ms === row.next_tick_ms;
|
|
128
|
+
}
|
package/connector/run.ts
CHANGED
|
@@ -413,6 +413,14 @@ const connector = startConnector({
|
|
|
413
413
|
const data = (await res.json()) as any;
|
|
414
414
|
return { id: data?.id };
|
|
415
415
|
},
|
|
416
|
+
async getLoops(botId) {
|
|
417
|
+
const id = requireIdentity(identities, botId);
|
|
418
|
+
const res = await fetch(`${API}/api/loops/mine`, {
|
|
419
|
+
headers: { Authorization: `Bearer ${id.token}` }, signal: AbortSignal.timeout(5000),
|
|
420
|
+
});
|
|
421
|
+
if (!res.ok) throw new PlatformHttpError(res.status);
|
|
422
|
+
return await res.json();
|
|
423
|
+
},
|
|
416
424
|
async getChatInfo(botId, chatId) {
|
|
417
425
|
const id = requireIdentity(identities, botId);
|
|
418
426
|
const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}`, {
|
package/connector/server.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { createServer, type Server as HttpServer, type IncomingMessage } from "n
|
|
|
15
15
|
import { WebSocketServer, WebSocket } from "ws";
|
|
16
16
|
import { verifyUpgradeToken, CLOSE_UNAUTHORIZED } from "./auth.ts";
|
|
17
17
|
import { buildDescriptor, type CapabilityDescriptor } from "./descriptor.ts";
|
|
18
|
-
import { toWireEvent, type AgentsChatMessage } from "./normalize.ts";
|
|
18
|
+
import { toWireEvent, serverLoopTick, matchesLiveLoop, type AgentsChatMessage } from "./normalize.ts";
|
|
19
19
|
import { MessageDedup } from "../src/dedup.ts";
|
|
20
20
|
import { IdentityTable, routeInboundTargets, hermesSourceProfile, type Identity } from "./identities.ts";
|
|
21
21
|
|
|
@@ -24,6 +24,8 @@ export interface AgentsChatHooks {
|
|
|
24
24
|
sendMessage(botId: string, chatId: string, content: string, replyTo?: string): Promise<{ id?: string }>;
|
|
25
25
|
getChatInfo(botId: string, chatId: string): Promise<{ name?: string; type?: string }>;
|
|
26
26
|
sendTyping?(botId: string, chatId: string): Promise<void>;
|
|
27
|
+
/** Read /api/loops/mine using this bot's own credentials; missing hook rejects self ticks. */
|
|
28
|
+
getLoops?(botId: string): Promise<unknown>;
|
|
27
29
|
/**
|
|
28
30
|
* Recent channel messages to attach as `context` when an @-mention arrives —
|
|
29
31
|
* the "what happened since the last time I was addressed" window. `sinceTs` is
|
|
@@ -293,15 +295,21 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
|
|
|
293
295
|
//
|
|
294
296
|
// DM: always forward. __botId (which identity's agentschat socket it arrived
|
|
295
297
|
// on) is the ownership signal; single-tenant falls back to its one identity.
|
|
296
|
-
// Group: forward
|
|
298
|
+
// Group: ordinary messages forward only when they @mention a fronted identity (content-
|
|
297
299
|
// based — the agentschat WS pushes every message of a joined channel
|
|
298
300
|
// unannotated, and arrival on a socket is NOT an addressing signal). The
|
|
299
301
|
// MCP path's gate is isDM || isMentioned; this reproduces it. Anything
|
|
300
302
|
// unaddressed is dropped — injecting joined-channel chatter into the
|
|
301
303
|
// agent's session would burn its tokens on messages not meant for it.
|
|
304
|
+
// Own server loop ticks bypass those gates only after live-record verification.
|
|
302
305
|
const isDm = typeof msg.channel_id === "string" && msg.channel_id.startsWith("dm-");
|
|
303
306
|
const owner = (msg as any).__botId ?? (msg as any).dm_owner;
|
|
304
|
-
const
|
|
307
|
+
const isTick = (msg.meta as any)?.kind === "loop_tick";
|
|
308
|
+
const tick = serverLoopTick(msg);
|
|
309
|
+
const loopTargets = tick ? table.all().filter(id => id.agentId === msg.sender_id) : [];
|
|
310
|
+
// A server loop addresses only its own identity, even if the prompt @mentions others.
|
|
311
|
+
// Never interpret a malformed/foreign loop as an ordinary mention or DM.
|
|
312
|
+
const targets = isTick ? (loopTargets.length === 1 ? loopTargets : []) : isDm
|
|
305
313
|
? [owner !== undefined ? (typeof owner === "string" ? table.forBot(owner) : null)
|
|
306
314
|
: table.isSingle() ? table.all()[0] : null].filter((id): id is Identity => !!id)
|
|
307
315
|
: routeInboundTargets(table, {
|
|
@@ -317,7 +325,14 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
|
|
|
317
325
|
const key = JSON.stringify([target.botId, msg.channel_id]);
|
|
318
326
|
const previous = inboundQueues.get(key) ?? Promise.resolve();
|
|
319
327
|
const job = previous.catch(() => {}).then(async () => {
|
|
320
|
-
if (
|
|
328
|
+
if (tick) {
|
|
329
|
+
let verified = false;
|
|
330
|
+
try { verified = !!hooks.getLoops && matchesLiveLoop(msg, target.agentId, await hooks.getLoops(target.botId)); } catch { /* fail closed */ }
|
|
331
|
+
if (!verified || table.forBot(target.botId) !== target) {
|
|
332
|
+
log("[connector] loop tick rejected: current bot loop could not be verified");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
} else if (msg.sender_id === target.agentId) return;
|
|
321
336
|
const baseEvent = toWireEvent(msg, "agentschat");
|
|
322
337
|
if (!baseEvent) return;
|
|
323
338
|
if (![...sockets].some(c => c.ws.readyState === WebSocket.OPEN && c.fronted.has(target.botId))) return;
|
|
@@ -325,6 +340,8 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
|
|
|
325
340
|
? JSON.stringify([msg.channel_id, msg.id, target.botId]) : null;
|
|
326
341
|
// Reserve synchronously, before async context fetch, across mirrored sockets.
|
|
327
342
|
if (deliveryKey && delivered.recordOrSkip(deliveryKey)) return;
|
|
343
|
+
// A mirrored/replayed tick must not run twice even if its envelope ID changes.
|
|
344
|
+
if (tick && delivered.recordOrSkip(JSON.stringify(["loop_tick", target.botId, msg.channel_id, tick.loop_id, tick.next_tick_ms]))) return;
|
|
328
345
|
if (!isDm) {
|
|
329
346
|
// Attach "what happened since you were last addressed" so the agent gets
|
|
330
347
|
// the conversation BETWEEN its @-mentions without being injected into
|
|
@@ -355,6 +372,12 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
|
|
|
355
372
|
if (msg.timestamp && Number.isFinite(Date.parse(msg.timestamp)) &&
|
|
356
373
|
(!since || Date.parse(msg.timestamp) > Date.parse(since))) lastAddressed.set(key, msg.timestamp);
|
|
357
374
|
}
|
|
375
|
+
if (tick) {
|
|
376
|
+
baseEvent.context = [...(baseEvent.context ?? []), {
|
|
377
|
+
text: `AgentsChat runtime context: this is your verified scheduled task in channel ${msg.channel_id}, running as ${target.agentId}. Execute it once in this same conversation; replies stay here. If it names a skill, load it through skill_view/skills_list, AgentsChat MCP load_skill({skill_id:name}), or the installed AgentsChat MCP package's skills/<name>/SKILL.md in this runtime. Missing skill or tools is a failure to report, not successful execution. Do not repeat the skill text or create another loop.`,
|
|
378
|
+
source: {user_name: "AgentsChat runtime"},
|
|
379
|
+
}];
|
|
380
|
+
}
|
|
358
381
|
const deliverTo = [...sockets].filter((c) => c.ws.readyState === WebSocket.OPEN && c.fronted.has(target.botId)).slice(0, 1);
|
|
359
382
|
if (deliverTo.length === 0) {
|
|
360
383
|
log(`[connector] inbound dropped for botId=${target.botId}: no agentschat-fronted gateway socket`);
|