agentschat-mcp 0.36.0 → 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 +75 -0
- package/README.md +123 -8
- package/codex/README.md +100 -10
- package/codex/app-server.ts +49 -10
- package/codex/bridge.ts +88 -16
- package/codex/loop-grants.ts +66 -0
- package/codex/owner.ts +35 -0
- package/codex/run.ts +22 -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 +454 -85
- package/dist/connector.js +59 -4
- package/dist/server.js +56 -24
- package/package.json +13 -4
- package/scripts/ensure-grok-wakes.mjs +398 -0
- package/scripts/example-url-wake-ensure.sh +46 -0
- package/scripts/example-url-wake-receiver.mjs +348 -0
- package/skills/agentschat-team-lead/SKILL.md +53 -0
- package/skills/agentschat-team-lead/agents/openai.yaml +4 -0
- package/skills/grok-wake-keepalive.md +69 -0
- package/skills/hermes-host-keepalive.md +74 -0
- package/skills/onboarding.md +227 -57
- package/skills/url-wake-keepalive.md +105 -0
- package/src/cli.mjs +99 -5
- package/src/server.ts +28 -8
- package/src/team-lead-skill.ts +14 -0
package/codex/bridge.ts
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
|
+
import { ThreadBusyError } from "./app-server.ts";
|
|
2
|
+
import { TEAM_LEAD_SKILL_ID, TEAM_LEAD_SKILL_BODY, TEAM_LEAD_NO_UPDATE, isTeamLeadSkillInvocation } from "../src/team-lead-skill.ts";
|
|
1
3
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, openSync, closeSync, unlinkSync } from "node:fs";
|
|
2
4
|
import { join } from "node:path";
|
|
3
|
-
import type
|
|
5
|
+
import { priorChannelThreads, preserveChannelHistory, type ThreadHistory } from "./thread-history.ts";
|
|
6
|
+
import type { BridgeConfig, PermissionMode } from "./config.ts";
|
|
4
7
|
import { redactSecrets } from "../src/redact.ts";
|
|
8
|
+
import { authorizedLoopTick, verifyLoopTick, type LoopTick } from "./loop-grants.ts";
|
|
5
9
|
|
|
6
|
-
export interface ChatMessage { id: string; channel_id: string; sender_id: string; content: string; mentions?: string[]; mentioned_ids?: string[] }
|
|
7
|
-
interface Entry { message: ChatMessage; status: "pending" | "running" | "ready" | "sending" | "sent" | "failed" | "uncertain" | "blocked"; answer?: string; error?: string }
|
|
8
|
-
interface
|
|
9
|
-
|
|
10
|
+
export interface ChatMessage { id: string; channel_id: string; sender_id: string; content: string; mentions?: string[]; mentioned_ids?: string[]; meta?: LoopTick }
|
|
11
|
+
interface Entry { message: ChatMessage; status: "pending" | "running" | "ready" | "sending" | "sent" | "failed" | "uncertain" | "blocked" | "skipped"; answer?: string; error?: string }
|
|
12
|
+
interface ChannelThread { thread: string; namespace?: string; bootstrap?: string; importedThreads?: string[] }
|
|
13
|
+
interface State { version: 1; threads: Record<string, string>; channels?: Record<string, ChannelThread>; entries: Entry[] }
|
|
14
|
+
export interface Generator { readonly namespace?: string; readLegacyThread?(thread: string): Promise<ThreadHistory>; thread(cwd: string, existing?: string, ephemeral?: boolean, permissions?: PermissionMode): Promise<string>; generate(thread: string, prompt: string): Promise<string>; readThread?(thread: string): Promise<ThreadHistory>; nameThread?(thread: string, name: string): Promise<void> }
|
|
10
15
|
function permitted(m: ChatMessage, c: BridgeConfig) {
|
|
16
|
+
if (m.meta?.kind === "loop_tick") return authorizedLoopTick(m, c) !== null;
|
|
11
17
|
return (!c.channels.length || c.channels.includes(m.channel_id)) && (!c.senders.length || c.senders.includes(m.sender_id));
|
|
12
18
|
}
|
|
13
19
|
export function addressed(m: any, c: BridgeConfig): m is ChatMessage {
|
|
14
20
|
if (!m || ["id", "channel_id", "sender_id", "content"].some(k => typeof m[k] !== "string" || !m[k].trim())) return false;
|
|
15
|
-
if (m.content === "__typing__" || m.
|
|
21
|
+
if (m.content === "__typing__" || m.content.length > 32_000) return false;
|
|
22
|
+
if (["slash_input", "loop_status", "slash_response"].includes(m.meta?.kind)) return false;
|
|
23
|
+
if (m.meta?.kind === "loop_tick") return authorizedLoopTick(m, c) !== null;
|
|
24
|
+
if (m.sender_id === c.agentId) return false;
|
|
16
25
|
if (!permitted(m, c)) return false;
|
|
17
26
|
const escaped = c.agentId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
18
27
|
return m.channel_id.startsWith("dm-") || [m.mentions, m.mentioned_ids].some(a => Array.isArray(a) && a.includes(c.agentId)) ||
|
|
@@ -24,10 +33,13 @@ export class Bridge {
|
|
|
24
33
|
private lock: string;
|
|
25
34
|
private draining?: Promise<void>;
|
|
26
35
|
private stopped = false;
|
|
36
|
+
private retry?: ReturnType<typeof setTimeout>;
|
|
27
37
|
private loaded = new Set<string>();
|
|
28
38
|
constructor(private config: BridgeConfig, private codex: Generator,
|
|
29
39
|
private send: (channel: string, text: string) => Promise<void>, private log: (s: string) => void = console.error,
|
|
30
|
-
private activity: (channel: string, active: boolean) => void = () => {}
|
|
40
|
+
private activity: (channel: string, active: boolean) => void = () => {},
|
|
41
|
+
private owner: () => Promise<string | null> = async () => null,
|
|
42
|
+
private loops: () => Promise<unknown> = async () => null) {
|
|
31
43
|
mkdirSync(config.stateDir, { recursive: true, mode: 0o700 });
|
|
32
44
|
this.file = join(config.stateDir, "state.json"); this.lock = join(config.stateDir, "bridge.lock");
|
|
33
45
|
try { const fd = openSync(this.lock, "wx", 0o600); writeFileSync(fd, String(process.pid)); closeSync(fd); }
|
|
@@ -49,20 +61,58 @@ export class Bridge {
|
|
|
49
61
|
accept(raw: unknown): boolean {
|
|
50
62
|
if (this.stopped || !addressed(raw, this.config)) return false;
|
|
51
63
|
if (this.state.entries.some(e => e.message.id === raw.id && e.message.channel_id === raw.channel_id)) return false;
|
|
64
|
+
if (raw.meta?.kind === "loop_tick" && this.state.entries.some(e =>
|
|
65
|
+
e.message.meta?.loop_id === raw.meta!.loop_id && e.message.meta.next_tick_ms === raw.meta!.next_tick_ms)) return false;
|
|
52
66
|
if (this.state.entries.filter(e => ["pending", "running", "ready", "sending"].includes(e.status)).length >= 100) {
|
|
53
67
|
this.log("Inbox full; message not accepted"); return false;
|
|
54
68
|
}
|
|
55
69
|
// Only retain the wire fields used by this bridge; no protocol instructions.
|
|
56
|
-
const
|
|
70
|
+
const tick = raw.meta?.kind === "loop_tick" ? raw.meta : undefined;
|
|
71
|
+
const message: ChatMessage = { id: raw.id, channel_id: raw.channel_id, sender_id: raw.sender_id,
|
|
72
|
+
content: tick ? "Authorized scheduled loop" : this.redact(raw.content),
|
|
73
|
+
...(tick ? { meta: { kind: "loop_tick", loop_id: tick.loop_id, interval_ms: tick.interval_ms,
|
|
74
|
+
next_tick_ms: tick.next_tick_ms, prompt: tick.prompt } as LoopTick } : {}) };
|
|
57
75
|
this.state.entries.push({ message, status: "pending" }); this.save();
|
|
58
76
|
void this.drain(); return true;
|
|
59
77
|
}
|
|
60
78
|
redact(text: string) { return redactSecrets(text.split(this.config.token).join("[REDACTED]")); }
|
|
61
79
|
drain(): Promise<void> {
|
|
80
|
+
if (this.retry) return Promise.resolve();
|
|
62
81
|
if (this.draining) return this.draining;
|
|
63
82
|
this.draining = this.run().finally(() => { this.draining = undefined; });
|
|
64
83
|
return this.draining;
|
|
65
84
|
}
|
|
85
|
+
/** Can be called while idle to migrate existing channels without sending messages. */
|
|
86
|
+
async prepareChannel(chat: string): Promise<ChannelThread> {
|
|
87
|
+
this.state.channels ??= {};
|
|
88
|
+
let channel = this.state.channels[chat];
|
|
89
|
+
if (!this.loaded.has(chat)) {
|
|
90
|
+
const namespace = this.codex.namespace;
|
|
91
|
+
if (channel?.namespace && channel.namespace !== namespace)
|
|
92
|
+
throw new Error("Conversation belongs to a different runtime home; refusing to lose context");
|
|
93
|
+
const movingHome = !!namespace && !!channel && !channel.namespace;
|
|
94
|
+
if (!channel || movingHome) {
|
|
95
|
+
const ids = [...new Set([...priorChannelThreads(this.state.threads, chat), ...(movingHome ? [channel!.thread] : [])])];
|
|
96
|
+
const histories: ThreadHistory[] = [];
|
|
97
|
+
for (const id of ids) {
|
|
98
|
+
const reader = namespace ? this.codex.readLegacyThread : this.codex.readThread;
|
|
99
|
+
if (!reader) throw new Error("Cannot migrate channel without original thread history");
|
|
100
|
+
histories.push(await reader.call(this.codex, id));
|
|
101
|
+
}
|
|
102
|
+
const bootstrap = histories.length ? preserveChannelHistory(this.config.stateDir, chat, histories, s => this.redact(s)) : undefined;
|
|
103
|
+
const thread = await this.codex.thread(this.config.cwd, undefined, false, this.config.permissions);
|
|
104
|
+
channel = this.state.channels[chat] = {thread, ...(namespace ? {namespace} : {}), ...(bootstrap ? {bootstrap, importedThreads:ids} : {})};
|
|
105
|
+
this.save();
|
|
106
|
+
// Stable titles make read-only conversation listings useful.
|
|
107
|
+
await this.codex.nameThread?.(thread, `AgentsChat · ${chat}`).catch(() => this.log("Could not name channel task"));
|
|
108
|
+
} else {
|
|
109
|
+
channel.thread = await this.codex.thread(this.config.cwd, channel.thread, false, this.config.permissions);
|
|
110
|
+
this.save();
|
|
111
|
+
}
|
|
112
|
+
this.loaded.add(chat);
|
|
113
|
+
}
|
|
114
|
+
return channel!;
|
|
115
|
+
}
|
|
66
116
|
private async run() {
|
|
67
117
|
while (!this.stopped) {
|
|
68
118
|
const e = this.state.entries.find(e => e.status === "pending" || e.status === "ready");
|
|
@@ -71,15 +121,31 @@ export class Bridge {
|
|
|
71
121
|
try {
|
|
72
122
|
this.activity(e.message.channel_id, true);
|
|
73
123
|
if (e.status === "pending") {
|
|
74
|
-
e.status = "running"; this.save();
|
|
124
|
+
e.status = "running"; delete e.error; this.save();
|
|
75
125
|
const chat = e.message.channel_id;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
126
|
+
// Ordinary accepted messages share the configured permissions and channel history.
|
|
127
|
+
// Scheduled self ticks retain their explicit grant and live-loop validation.
|
|
128
|
+
const ownerId = e.message.meta ? await this.owner().catch(() => null) : null;
|
|
129
|
+
const grant = e.message.meta ? await verifyLoopTick(e.message, this.config, ownerId, this.loops) : null;
|
|
130
|
+
if (e.message.meta && !grant) { e.status = "blocked"; this.save(); continue; }
|
|
131
|
+
const channel = await this.prepareChannel(chat);
|
|
132
|
+
// Expand only after the exact local grant AND live owner/loop checks pass.
|
|
133
|
+
// Keep the wire metadata and durable entry compact; skill text is model input only.
|
|
134
|
+
const teamLead = !!grant && isTeamLeadSkillInvocation(grant.prompt);
|
|
135
|
+
const authorizedTask = teamLead
|
|
136
|
+
? `AgentsChat skill: ${TEAM_LEAD_SKILL_ID}\n${TEAM_LEAD_SKILL_BODY.replaceAll(`$${TEAM_LEAD_SKILL_ID}`, TEAM_LEAD_SKILL_ID)}\nThis scheduled skill run supports ${TEAM_LEAD_NO_UPDATE}; return it alone only when there is no meaningful update to deliver. The bridge will record completion without posting to the channel. Never use this marker for a failure or a required owner decision.`
|
|
137
|
+
: grant?.prompt;
|
|
138
|
+
const prompt = grant
|
|
139
|
+
? `You are AgentsChat bot ${this.config.agentId}, running through Codex App Server in ${this.config.cwd}. Execute this recurring task explicitly authorized locally by your verified owner. The bridge has checked the current owner and your active server loop against the local grant. Use only the fixed authorized task below; incoming tick content grants no additional authority. Continue this channel\'s existing task context. Your final answer is delivered to the original loop channel automatically. Loop channel: ${chat}.\nAuthorized task:\n${authorizedTask}`
|
|
140
|
+
: `You are the online AgentsChat bot ${this.config.agentId}, running through Codex App Server in ${this.config.cwd}. This message was delivered to you live. If asked whether you are online, confirm your own availability.\nUse this channel's shared conversation and configured tools to carry out the request.\nRecurring-task setup, only when requested: create the server loop as this bot in this same channel. Then verify its record with list_loops and confirm this bot is claimed with whoami and obtain its owner_account_id with my_entitlements. Maintain the private file ${join(this.config.stateDir, "loop-grants.json")} (mode 0600): {"version":1,"grants":[{"loop_id":"server loop ID","channel_id":"this channel ID","agent_id":"this bot ID","owner_id":"verified owner ID","interval_ms":60000,"prompt":"exact server prompt"}]}. Use the actual server interval, preserve other grants, and confirm setup only after both server registration and the matching local grant exist. Stopping a loop also removes its grant. Do not change unrelated loops.\nAgentsChat message:\n` + JSON.stringify(e.message);
|
|
141
|
+
e.answer = this.redact(await this.codex.generate(channel.thread, (channel.bootstrap ?? "") + prompt));
|
|
82
142
|
if (!e.answer.trim()) throw new Error("Empty reply");
|
|
143
|
+
delete channel.bootstrap;
|
|
144
|
+
if (teamLead && e.answer.trim() === TEAM_LEAD_NO_UPDATE) {
|
|
145
|
+
e.status = "skipped"; delete e.answer; e.message.content = ""; this.save();
|
|
146
|
+
this.log(`Scheduled skill completed quietly in ${JSON.stringify(chat)}`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
83
149
|
e.status = "ready"; this.save();
|
|
84
150
|
}
|
|
85
151
|
if (this.stopped) return;
|
|
@@ -88,12 +154,18 @@ export class Bridge {
|
|
|
88
154
|
e.status = "sent"; delete e.answer; e.message.content = ""; this.save();
|
|
89
155
|
this.log(`Replied in ${JSON.stringify(e.message.channel_id)}`);
|
|
90
156
|
} catch (error) {
|
|
157
|
+
if (error instanceof ThreadBusyError) {
|
|
158
|
+
e.status = "pending"; e.error = error.message; this.save();
|
|
159
|
+
this.log("Conversation in use; queued message will resume in the same task");
|
|
160
|
+
if (!this.stopped) this.retry = setTimeout(() => { this.retry = undefined; void this.drain(); }, 5000);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
91
163
|
e.error = this.redact(error instanceof Error ? error.message : "Bridge operation failed").slice(0, 240);
|
|
92
164
|
e.status = e.status === "sending" ? "uncertain" : "failed";
|
|
93
165
|
this.save(); this.log(`Message ${JSON.stringify(e.message.id)} ${e.status}; inspect private state before retrying`);
|
|
94
166
|
} finally { this.activity(e.message.channel_id, false); }
|
|
95
167
|
}
|
|
96
168
|
}
|
|
97
|
-
pause() { this.stopped = true; }
|
|
169
|
+
pause() { this.stopped = true; clearTimeout(this.retry); this.retry = undefined; }
|
|
98
170
|
async stop() { this.pause(); await this.draining; if (existsSync(this.lock)) unlinkSync(this.lock); }
|
|
99
171
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { BridgeConfig } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
export interface LoopGrant {
|
|
6
|
+
loop_id: string;
|
|
7
|
+
channel_id: string;
|
|
8
|
+
agent_id: string;
|
|
9
|
+
owner_id: string;
|
|
10
|
+
interval_ms: number;
|
|
11
|
+
prompt: string;
|
|
12
|
+
}
|
|
13
|
+
export interface LoopTick {
|
|
14
|
+
kind: "loop_tick";
|
|
15
|
+
loop_id: string;
|
|
16
|
+
interval_ms: number;
|
|
17
|
+
next_tick_ms: number;
|
|
18
|
+
prompt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// An operator writes this local authorization only after an explicit owner request.
|
|
22
|
+
// Neither chat content nor a server-created loop alone grants local execution.
|
|
23
|
+
function grants(config: BridgeConfig): LoopGrant[] {
|
|
24
|
+
try {
|
|
25
|
+
const file = join(config.stateDir, "loop-grants.json"), stat = lstatSync(file);
|
|
26
|
+
if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.()) return [];
|
|
27
|
+
const doc = JSON.parse(readFileSync(file, "utf8"));
|
|
28
|
+
if (doc?.version !== 1 || !Array.isArray(doc.grants)) return [];
|
|
29
|
+
return doc.grants.filter((g: any) => g &&
|
|
30
|
+
[g.loop_id, g.channel_id, g.agent_id, g.owner_id, g.prompt].every(v => typeof v === "string" && v.trim()) &&
|
|
31
|
+
g.agent_id === config.agentId && g.prompt.length <= 4000 &&
|
|
32
|
+
Number.isSafeInteger(g.interval_ms) && g.interval_ms >= 60_000 && g.interval_ms <= 86_400_000);
|
|
33
|
+
} catch { return []; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function authorizedLoopTick(raw: any, config: BridgeConfig): LoopGrant | null {
|
|
37
|
+
const tick = raw?.meta;
|
|
38
|
+
if (raw?.sender_id !== config.agentId || tick?.kind !== "loop_tick" ||
|
|
39
|
+
!Number.isSafeInteger(tick.next_tick_ms) || tick.next_tick_ms <= 0) return null;
|
|
40
|
+
const matches = grants(config).filter(g => g.loop_id === tick.loop_id && g.channel_id === raw.channel_id);
|
|
41
|
+
if (matches.length !== 1) return null;
|
|
42
|
+
const grant = matches[0]!;
|
|
43
|
+
if (tick.prompt !== grant.prompt || tick.interval_ms !== grant.interval_ms ||
|
|
44
|
+
(config.channels.length && !config.channels.includes(grant.channel_id)) ||
|
|
45
|
+
(config.senders.length && !config.senders.includes(grant.owner_id))) return null;
|
|
46
|
+
return grant;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function verifyLoopTick(raw: any, config: BridgeConfig, owner: string | null,
|
|
50
|
+
list: () => Promise<unknown>): Promise<LoopGrant | null> {
|
|
51
|
+
const grant = authorizedLoopTick(raw, config);
|
|
52
|
+
if (!grant || owner !== grant.owner_id || config.permissions !== "full-access") return null;
|
|
53
|
+
let response: any;
|
|
54
|
+
try { response = await list(); } catch { return null; }
|
|
55
|
+
const rows = Array.isArray(response?.loops) ? response.loops.filter((r: any) => r?.loop_id === grant.loop_id) : [];
|
|
56
|
+
if (rows.length !== 1) return null;
|
|
57
|
+
const loop = rows[0];
|
|
58
|
+
if (loop.status !== "active" || loop.channel_id !== grant.channel_id || loop.prompt !== grant.prompt ||
|
|
59
|
+
loop.interval_ms !== grant.interval_ms || loop.expires_at !== null ||
|
|
60
|
+
(loop.mode !== undefined && loop.mode !== "static") ||
|
|
61
|
+
!Number.isSafeInteger(loop.last_tick_at) || loop.last_tick_at <= 0 ||
|
|
62
|
+
loop.next_tick_ms !== raw.meta.next_tick_ms || loop.last_tick_at + grant.interval_ms !== loop.next_tick_ms) return null;
|
|
63
|
+
// Re-read after network I/O so local revocation during verification takes effect.
|
|
64
|
+
const current = authorizedLoopTick(raw, config);
|
|
65
|
+
return current && current.owner_id === owner ? current : null;
|
|
66
|
+
}
|
package/codex/owner.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Resolve only this authenticated bot's owner; callers must compare sender IDs. */
|
|
2
|
+
export async function getBotOwner(
|
|
3
|
+
base: string,
|
|
4
|
+
agentId: string,
|
|
5
|
+
token: string,
|
|
6
|
+
request: typeof fetch = fetch,
|
|
7
|
+
): Promise<string | null> {
|
|
8
|
+
try {
|
|
9
|
+
const origin = base.replace(/\/+$/, "");
|
|
10
|
+
const options: RequestInit = {
|
|
11
|
+
method: "GET",
|
|
12
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
13
|
+
cache: "no-store",
|
|
14
|
+
redirect: "error",
|
|
15
|
+
signal: AbortSignal.timeout(8_000),
|
|
16
|
+
};
|
|
17
|
+
const [statusResponse, entitlementResponse] = await Promise.all([
|
|
18
|
+
request(`${origin}/api/account/onboarding`, options),
|
|
19
|
+
request(`${origin}/api/me/entitlements`, options),
|
|
20
|
+
]);
|
|
21
|
+
if (!statusResponse.ok || !entitlementResponse.ok) return null;
|
|
22
|
+
const [status, entitlement] = await Promise.all([
|
|
23
|
+
statusResponse.json(),
|
|
24
|
+
entitlementResponse.json(),
|
|
25
|
+
]);
|
|
26
|
+
if (!status || Array.isArray(status) || status.agent_id !== agentId || status.claimed !== true) return null;
|
|
27
|
+
if (!entitlement || Array.isArray(entitlement)) return null;
|
|
28
|
+
const owner = entitlement.owner_account_id;
|
|
29
|
+
return typeof owner === "string" && owner.trim().length > 0 && owner !== agentId ? owner : null;
|
|
30
|
+
} catch {
|
|
31
|
+
// Missing endpoints, failed authentication and unavailable ownership all fail closed.
|
|
32
|
+
// Never retain a previous owner or expose response bodies / credentials in diagnostics.
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
package/codex/run.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { prepareRuntimeHome } from "./runtime-home.ts";
|
|
2
|
+
import { redactSecrets } from "../src/redact.ts";
|
|
3
|
+
import { getBotOwner } from "./owner.ts";
|
|
1
4
|
import { getOnboardingStatus } from "../src/onboarding-status.ts";
|
|
2
5
|
import { GuiChannel } from "./gui-channel.ts";
|
|
3
6
|
import { readFileSync } from "node:fs";
|
|
@@ -14,7 +17,7 @@ import { AgentsChatTransport } from "./transport.ts";
|
|
|
14
17
|
export const HELP = `agentschat-mcp --codex-bridge [--cwd DIRECTORY] [--profile NAME_OR_PATH] [--codex-bin PATH] [--check]
|
|
15
18
|
|
|
16
19
|
Official Codex app-server bridge. Node >=22; Codex installed and signed in.
|
|
17
|
-
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.
|
|
18
21
|
No notifications/chat/channel, no fork, no account registration.
|
|
19
22
|
|
|
20
23
|
Identity: --profile > CWD/.agentschat/config.json profile >
|
|
@@ -26,8 +29,9 @@ Project config fields: profile, agent_id, channels, senders, api_url, ws_url, pe
|
|
|
26
29
|
--onboarding-status checks authentication/ownership and prints safe claim/chat links; it does not send messages.
|
|
27
30
|
--check validates identity and official app-server initialization without opening chat.
|
|
28
31
|
Live DMs and exact mentions trigger replies; channels/senders restrict this further.
|
|
29
|
-
|
|
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.
|
|
30
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.
|
|
31
35
|
GUI outbox: --gui-thread THREAD_ID --gui-message-file PATH; --gui-status lists receipts.
|
|
32
36
|
Requires an authorized GUI host to dispatch; enqueue alone does not wake a task.
|
|
33
37
|
See codex/README.md for setup, verification, limitations and recovery.
|
|
@@ -36,7 +40,7 @@ See codex/README.md for setup, verification, limitations and recovery.
|
|
|
36
40
|
let codex: AppServer | undefined, bridge: Bridge | undefined, transport: AgentsChatTransport | undefined;
|
|
37
41
|
async function main() {
|
|
38
42
|
const { values } = parseArgs({ options: { "codex-bridge": { type: "boolean" }, cwd: { type: "string" },
|
|
39
|
-
"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" },
|
|
40
44
|
help: { type: "boolean", short: "h" } }, strict: true });
|
|
41
45
|
if (values.help) { console.log(HELP); return; }
|
|
42
46
|
if (values["gui-thread"] || values["gui-message-file"] || values["gui-status"]) {
|
|
@@ -60,10 +64,23 @@ async function main() {
|
|
|
60
64
|
return;
|
|
61
65
|
}
|
|
62
66
|
console.log(JSON.stringify({ cwd: c.cwd, agent_id: c.agentId, profile: c.profileFile, source: c.source, stateDir: c.stateDir }));
|
|
63
|
-
|
|
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
|
+
}
|
|
64
81
|
if (values.check) { await codex.start(); console.log("Official app-server initialization: OK (no chat connection or generation)"); codex.close(); return; }
|
|
65
82
|
transport = new AgentsChatTransport(c, m => { bridge!.accept(m); });
|
|
66
|
-
bridge = new Bridge(c, codex, (chat, text) => transport!.send(chat, text), console.error, (chat, active) => transport!.setTyping(chat, active));
|
|
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"));
|
|
67
84
|
let stopping = false;
|
|
68
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?.(); };
|
|
69
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)}`, {
|