agentschat-mcp 0.36.5 → 0.36.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/codex/README.md +14 -0
- package/codex/app-server.ts +14 -15
- package/codex/bots-config.ts +2 -2
- package/codex/bridge.ts +9 -0
- package/codex/config.ts +12 -4
- package/codex/inbox-sync.ts +92 -0
- package/codex/manager.ts +1 -1
- package/codex/run.ts +4 -4
- package/codex/transport.ts +21 -6
- package/dist/codex-bots.js +12 -4
- package/dist/codex-bridge.js +193 -43
- package/dist/server.js +189 -9
- package/package.json +5 -2
- package/scripts/ensure-grok-wakes.mjs +68 -19
- package/scripts/example-url-wake-ensure.sh +9 -0
- package/scripts/example-url-wake-receiver.mjs +22 -1
- package/scripts/grok-bind-register.sh +159 -0
- package/skills/agentschat-team-lead/SKILL.md +20 -10
- package/skills/grok-wake-keepalive.md +54 -10
- package/skills/onboarding.md +31 -6
- package/skills/url-wake-keepalive.md +7 -0
- package/src/grok-bind.ts +73 -0
- package/src/identity-guard.ts +147 -0
- package/src/server.ts +121 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Release notes
|
|
2
2
|
|
|
3
|
+
## 0.36.7 — UNPUBLISHED — Non-Grok stacks start without Cursor session env
|
|
4
|
+
|
|
5
|
+
- **Codex execution ownership:** remove outer turn/RPC time limits and duration-based restarts; Codex owns long-running execution. Explicit shutdown and actual backend failures still preserve pending work and conversation mappings.
|
|
6
|
+
- **Codex message recovery:** persist independent history checkpoints and reconcile missed messages on reconnect and every minute, with bounded pages, durable deduplication, and inbox backpressure. Cold installation does not replay old requests.
|
|
7
|
+
- **Codex bot effort:** optional per-bot `effort` overrides reasoning on every new turn, including resumed conversations, without changing the model or other bots. Manager status exposes the configured override.
|
|
8
|
+
- **Continuous team delivery:** maintain a capacity- and capability-aware reserve of executable tasks, replenish before members run out, and coordinate completion-driven claims without duplicate writers or artificial busywork. Runtime wake limitations stay explicit.
|
|
9
|
+
- **Docs:** onboarding, `url-wake-keepalive` and `grok-wake-keepalive` say
|
|
10
|
+
non-Grok stacks (URL wakes such as Antigravity/ZCode, Hermes) are started with
|
|
11
|
+
the Cursor session env removed (`env -u CURSOR_CONVERSATION_ID -u CURSOR_REQUEST_ID -u __CURSOR_SANDBOX_ENV_RESTORE -u CURSOR_AGENT` + dynamic
|
|
12
|
+
`CURSOR_AGENT_STORE_*`), and that Grok `WAKE_MODE=grok` wakes keep theirs.
|
|
13
|
+
- **Example receiver:** `withoutCursorSessionEnv()` drops those keys from the host
|
|
14
|
+
turn's env; `example-url-wake-ensure.sh` shows the start-script pattern.
|
|
15
|
+
|
|
16
|
+
## 0.36.6 — UNPUBLISHED — Grok binds: register-yourself, non-Grok wakes exempt
|
|
17
|
+
|
|
18
|
+
- **grok-bind no longer captures non-Grok wakes:** identity bind, heal and the
|
|
19
|
+
`switch_profile` lock are skipped when the process is tagged as another wake
|
|
20
|
+
stack (`AGENTCHAT_ANTIGRAVITY_WAKE` set, or `AGENTCHAT_WAKE_KIND` /
|
|
21
|
+
`AGENTCHAT_WAKE_MODE` other than `grok`) or was started with an explicit
|
|
22
|
+
`--profile` that differs from the bound profile. Antigravity/ZCode bots that
|
|
23
|
+
inherited a Grok agent's `CURSOR_CONVERSATION_ID` can switch to their own
|
|
24
|
+
profiles again. Grok wakes and Cursor outbound MCP (explicit profile equal to
|
|
25
|
+
the bound one) stay locked. `switch_profile` listing ignores
|
|
26
|
+
`grok-binds.meta.json`.
|
|
27
|
+
- **Register yourself (`scripts/grok-bind-register.sh`, bin
|
|
28
|
+
`agentschat-grok-bind-register`):** the only writer of grok-binds.json. Uses
|
|
29
|
+
the caller's own `CURSOR_CONVERSATION_ID` (real UUIDs only), requires the
|
|
30
|
+
profile file, flocks, sets only its own key atomically (mode 600) and records
|
|
31
|
+
`{profile, registered_by, ts}` in the `grok-binds.meta.json` sidecar.
|
|
32
|
+
`--prune` removes an entry only if its profile is gone, or its agent dir is
|
|
33
|
+
missing and it last registered more than 7 days ago; each prune is logged.
|
|
34
|
+
No hard-coded bot list.
|
|
35
|
+
- **ensure-grok-wakes:** a missing binds file prunes nothing; every stopped
|
|
36
|
+
orphan is logged with profile, agent id and reason; the script never writes
|
|
37
|
+
the binds file.
|
|
38
|
+
- **Docs:** `grok-wake-keepalive` and onboarding describe the register-yourself
|
|
39
|
+
flow and tagging non-Grok hosts.
|
|
40
|
+
|
|
3
41
|
## 0.36.5 — UNPUBLISHED — Shared conversations and team coordination
|
|
4
42
|
|
|
5
43
|
- **Cross-runtime group loops:** own server ticks no longer require an @mention for Claude/Grok MCP notification and wake delivery. Hermes Relay accepts verified current bot-owned ticks in the original group and deduplicates replay; native skill-loader guidance stays in private runtime context.
|
package/codex/README.md
CHANGED
|
@@ -106,6 +106,17 @@ so already. The bridge never writes an account token into project config or stat
|
|
|
106
106
|
DMs and different groups stay separate. Permissions and owner lookup no longer
|
|
107
107
|
split ordinary chat history. Requests run in arrival order; restart resumes the
|
|
108
108
|
same thread. Up to 100 unfinished messages can queue.
|
|
109
|
+
- Codex owns execution lifetime. The bridge sets no turn-duration or App Server
|
|
110
|
+
RPC deadline and never restarts a healthy backend merely because it is slow.
|
|
111
|
+
Explicit shutdown, process exit, broken pipes and invalid protocol responses
|
|
112
|
+
still end pending requests; actual backend failure is handled by the supervisor.
|
|
113
|
+
- After first installation, durable REST checkpoints recover missed messages on
|
|
114
|
+
reconnect and once per minute while connected. Each pass reads at most 50 rows
|
|
115
|
+
per subscribed channel; subsequent passes continue a backlog. Live and recovered
|
|
116
|
+
messages share identity checks, inbox deduplication and the same conversation.
|
|
117
|
+
First-time membership starts from now, without replaying historical requests.
|
|
118
|
+
Inbox pressure preserves the checkpoint for retry. `inbound-cursors.json` records
|
|
119
|
+
the last check, last successful check and any reconciliation error for diagnosis.
|
|
109
120
|
- All accepted messages use the bot's configured permissions: full access by
|
|
110
121
|
default (`approvalPolicy=never`, `danger-full-access`, inherited MCP tools).
|
|
111
122
|
Use `channels`/`senders` to limit which messages the bot accepts, or explicit
|
|
@@ -254,6 +265,9 @@ Create `~/.agentschat/codex-bots.json`:
|
|
|
254
265
|
```
|
|
255
266
|
|
|
256
267
|
Omitted default_workdir uses `~/.agentschat/workspace` (created automatically).
|
|
268
|
+
Optional per-bot `"effort": "medium"` sets reasoning effort on every turn,
|
|
269
|
+
including existing conversations. Omit it to retain Codex's inherited effort.
|
|
270
|
+
It does not change the model, other bots, or the desktop's global configuration.
|
|
257
271
|
Relative workdirs resolve against the registry's directory. Each enabled bot has
|
|
258
272
|
its own bridge process, App Server, inbox and channel threads. Identity and routing
|
|
259
273
|
come exclusively from the registry and named profile; project profile settings and
|
package/codex/app-server.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
3
|
import type { ThreadHistory } from "./thread-history.ts";
|
|
4
|
-
import type { PermissionMode } from "./config.ts";
|
|
4
|
+
import type { PermissionMode, ReasoningEffort } from "./config.ts";
|
|
5
5
|
|
|
6
6
|
export class ThreadBusyError extends Error {
|
|
7
7
|
constructor() { super("This conversation has another active writer; waiting to resume the same task"); this.name = "ThreadBusyError"; }
|
|
@@ -13,12 +13,13 @@ export class AppServer {
|
|
|
13
13
|
private closed = false;
|
|
14
14
|
private child?: ChildProcessWithoutNullStreams;
|
|
15
15
|
private nextId = 0;
|
|
16
|
-
private pending = new Map<number, { method: string; resolve: (v: any) => void; reject: (e: Error) => void
|
|
16
|
+
private pending = new Map<number, { method: string; resolve: (v: any) => void; reject: (e: Error) => void }>();
|
|
17
17
|
private active?: { thread: string; turn?: string; items: Map<string, string>; early: any[];
|
|
18
|
-
resolve: (s: string) => void; reject: (e: Error) => void
|
|
18
|
+
resolve: (s: string) => void; reject: (e: Error) => void };
|
|
19
19
|
private threadPermissions = new Map<string, PermissionMode>();
|
|
20
20
|
private disabledMcp: Record<string, { enabled: boolean }> = {};
|
|
21
|
-
|
|
21
|
+
// Keep the legacy positional argument for callers; turns have no time limit.
|
|
22
|
+
constructor(private bin = "codex", private args = ["app-server", "--listen", "stdio://"], _legacyTurnTimeoutMs?: number, private permissions: PermissionMode = "full-access", private runtime?: {home: string; legacyHome?: string}, private effort?: ReasoningEffort) {}
|
|
22
23
|
get namespace() { return this.runtime?.home; }
|
|
23
24
|
async start() {
|
|
24
25
|
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^AGENTS?CHAT_|^RELAY_/.test(k)));
|
|
@@ -44,10 +45,9 @@ export class AppServer {
|
|
|
44
45
|
request(method: string, params: unknown): Promise<any> {
|
|
45
46
|
return new Promise((resolve, reject) => {
|
|
46
47
|
const id = ++this.nextId;
|
|
47
|
-
|
|
48
|
-
this.pending.set(id, { method, resolve, reject, timer });
|
|
48
|
+
this.pending.set(id, { method, resolve, reject });
|
|
49
49
|
try { this.write({ id, method, params }); }
|
|
50
|
-
catch (e) {
|
|
50
|
+
catch (e) { this.pending.delete(id); reject(e); }
|
|
51
51
|
});
|
|
52
52
|
}
|
|
53
53
|
private receive(message: any) {
|
|
@@ -58,7 +58,7 @@ export class AppServer {
|
|
|
58
58
|
}
|
|
59
59
|
if (message.id !== undefined) {
|
|
60
60
|
const waiter = this.pending.get(message.id);
|
|
61
|
-
if (waiter) {
|
|
61
|
+
if (waiter) { this.pending.delete(message.id);
|
|
62
62
|
message.error ? waiter.reject(waiter.method === "thread/resume" && /already has an active writer/i.test(message.error.message ?? "")
|
|
63
63
|
? new ThreadBusyError() : new Error(`App-server request rejected (${message.error.code})`)) : waiter.resolve(message.result); }
|
|
64
64
|
return;
|
|
@@ -70,7 +70,7 @@ export class AppServer {
|
|
|
70
70
|
if (message.method === "item/completed" && p.item?.type === "agentMessage" &&
|
|
71
71
|
(!p.item.phase || p.item.phase === "final_answer")) a.items.set(p.item.id, p.item.text);
|
|
72
72
|
if (message.method === "turn/completed") {
|
|
73
|
-
|
|
73
|
+
this.active = undefined;
|
|
74
74
|
if (p.turn.status !== "completed") { a.reject(new Error(`Codex turn ${p.turn.status}`)); return; }
|
|
75
75
|
for (const item of p.turn.items ?? []) if (item.type === "agentMessage" && (!item.phase || item.phase === "final_answer")) a.items.set(item.id, item.text);
|
|
76
76
|
const text = [...a.items.values()].join("\n").trim();
|
|
@@ -110,19 +110,18 @@ export class AppServer {
|
|
|
110
110
|
}
|
|
111
111
|
async readLegacyThread(thread: string): Promise<ThreadHistory> {
|
|
112
112
|
if (!this.runtime?.legacyHome) return this.readThread(thread);
|
|
113
|
-
const reader = new AppServer(this.bin, undefined,
|
|
113
|
+
const reader = new AppServer(this.bin, undefined, undefined, this.permissions, {home:this.runtime.legacyHome});
|
|
114
114
|
try { await reader.start(); return await reader.readThread(thread); } finally { reader.close(); }
|
|
115
115
|
}
|
|
116
116
|
async nameThread(thread: string, name: string): Promise<void> {
|
|
117
117
|
await this.request("thread/name/set", {threadId:thread, name});
|
|
118
118
|
}
|
|
119
|
-
async generate(thread: string, text: string, effort
|
|
119
|
+
async generate(thread: string, text: string, effort: ReasoningEffort | undefined = this.effort): Promise<string> {
|
|
120
120
|
if (this.active) throw new Error("App-server is busy");
|
|
121
121
|
const permissions = this.threadPermissions.get(thread);
|
|
122
122
|
if (!permissions) throw new Error("Thread permissions have not been configured");
|
|
123
123
|
const completed = new Promise<string>((resolve, reject) => {
|
|
124
|
-
this.active = { thread, items: new Map(), early: [], resolve, reject
|
|
125
|
-
timer: setTimeout(() => this.fatal(new Error("Codex turn timed out")), this.timeoutMs) };
|
|
124
|
+
this.active = { thread, items: new Map(), early: [], resolve, reject };
|
|
126
125
|
});
|
|
127
126
|
// Attach immediately, including while turn/start is waiting for its response.
|
|
128
127
|
void completed.catch(() => {});
|
|
@@ -138,8 +137,8 @@ export class AppServer {
|
|
|
138
137
|
} catch (e) { this.fail(e instanceof Error ? e : new Error("Generation failed")); throw e; }
|
|
139
138
|
}
|
|
140
139
|
private fail(error: Error) {
|
|
141
|
-
for (const p of this.pending.values())
|
|
142
|
-
if (this.active) {
|
|
140
|
+
for (const p of this.pending.values()) p.reject(error); this.pending.clear();
|
|
141
|
+
if (this.active) { this.active.reject(error); this.active = undefined; }
|
|
143
142
|
}
|
|
144
143
|
private fatal(error: Error) {
|
|
145
144
|
if (this.closed) return;
|
package/codex/bots-config.ts
CHANGED
|
@@ -21,7 +21,7 @@ export function loadBots(file = defaultRegistry(), home = homedir()): BotConfig[
|
|
|
21
21
|
const names = new Set<string>(), identities = new Set<string>();
|
|
22
22
|
const bots: BotConfig[] = [];
|
|
23
23
|
for (const bot of doc.bots) {
|
|
24
|
-
fields(bot, ["name", "profile", "workdir", "enabled", "agent_id", "channels", "senders", "api_url", "ws_url", "permissions"]);
|
|
24
|
+
fields(bot, ["name", "profile", "workdir", "enabled", "agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort"]);
|
|
25
25
|
if (!text(bot.name) || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(bot.name) || names.has(bot.name)) throw new Error("Bot names must be unique simple labels");
|
|
26
26
|
names.add(bot.name);
|
|
27
27
|
if (bot.enabled !== undefined && typeof bot.enabled !== "boolean") throw new Error("Invalid bot enabled flag");
|
|
@@ -32,7 +32,7 @@ export function loadBots(file = defaultRegistry(), home = homedir()): BotConfig[
|
|
|
32
32
|
if (!doc.default_workdir && !bot.workdir) mkdirSync(defaultDir, { recursive: true, mode: 0o700 });
|
|
33
33
|
const cwd = realpathSync(bot.workdir ? path(bot.workdir) : defaultDir);
|
|
34
34
|
const settings: IdentitySettings = {};
|
|
35
|
-
for (const k of ["agent_id", "channels", "senders", "api_url", "ws_url", "permissions"] as const) if (bot[k] !== undefined) (settings as any)[k] = bot[k];
|
|
35
|
+
for (const k of ["agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort"] as const) if (bot[k] !== undefined) (settings as any)[k] = bot[k];
|
|
36
36
|
const config = resolveConfig({ cwd, profile: bot.profile, settings, codexBin: doc.codex_bin }, {}, home);
|
|
37
37
|
const identity = JSON.stringify([config.apiUrl, config.agentId]);
|
|
38
38
|
if (identities.has(identity)) throw new Error("Duplicate AgentsChat account in enabled bots (even with different workdirs)");
|
package/codex/bridge.ts
CHANGED
|
@@ -76,6 +76,15 @@ export class Bridge {
|
|
|
76
76
|
void this.drain(); return true;
|
|
77
77
|
}
|
|
78
78
|
redact(text: string) { return redactSecrets(text.split(this.config.token).join("[REDACTED]")); }
|
|
79
|
+
/** History cursors may advance past ignored/known messages, never a full inbox. */
|
|
80
|
+
recover(raw: unknown): boolean {
|
|
81
|
+
if (this.stopped) return false;
|
|
82
|
+
if (!addressed(raw, this.config)) return true;
|
|
83
|
+
if (this.state.entries.some(e => e.message.id === raw.id && e.message.channel_id === raw.channel_id)) return true;
|
|
84
|
+
if (raw.meta?.kind === "loop_tick" && this.state.entries.some(e =>
|
|
85
|
+
e.message.meta?.loop_id === raw.meta!.loop_id && e.message.meta.next_tick_ms === raw.meta!.next_tick_ms)) return true;
|
|
86
|
+
return this.accept(raw);
|
|
87
|
+
}
|
|
79
88
|
drain(): Promise<void> {
|
|
80
89
|
if (this.retry) return Promise.resolve();
|
|
81
90
|
if (this.draining) return this.draining;
|
package/codex/config.ts
CHANGED
|
@@ -6,6 +6,14 @@ import { validateIdentityProfile } from "../src/identity.ts";
|
|
|
6
6
|
import { parse as parseToml } from "smol-toml";
|
|
7
7
|
|
|
8
8
|
export type PermissionMode = "full-access" | "read-only";
|
|
9
|
+
// App Server advertises model-specific, nonempty effort names via model/list.
|
|
10
|
+
export type ReasoningEffort = string;
|
|
11
|
+
export function reasoningEffort(value: unknown): ReasoningEffort | undefined {
|
|
12
|
+
if (value === undefined) return undefined;
|
|
13
|
+
if (typeof value !== "string" || !value.trim())
|
|
14
|
+
throw new Error("effort must be a nonempty model-supported reasoning effort name");
|
|
15
|
+
return value.trim();
|
|
16
|
+
}
|
|
9
17
|
export function permissionMode(value: unknown): PermissionMode {
|
|
10
18
|
if (value === undefined) return "full-access";
|
|
11
19
|
if (value !== "full-access" && value !== "read-only") throw new Error("permissions must be full-access or read-only");
|
|
@@ -14,9 +22,9 @@ export function permissionMode(value: unknown): PermissionMode {
|
|
|
14
22
|
export interface BridgeConfig {
|
|
15
23
|
cwd: string; profileFile: string; source: string; agentId: string; token: string;
|
|
16
24
|
apiUrl: string; wsUrl: string; channels: string[]; senders: string[];
|
|
17
|
-
codexBin: string; stateDir: string; permissions: PermissionMode;
|
|
25
|
+
codexBin: string; stateDir: string; permissions: PermissionMode; effort?: ReasoningEffort;
|
|
18
26
|
}
|
|
19
|
-
export interface IdentitySettings { permissions?: PermissionMode; profile?: string; agent_id?: string; channels?: string[]; senders?: string[]; api_url?: string; ws_url?: string }
|
|
27
|
+
export interface IdentitySettings { permissions?: PermissionMode; effort?: ReasoningEffort; profile?: string; agent_id?: string; channels?: string[]; senders?: string[]; api_url?: string; ws_url?: string }
|
|
20
28
|
function readJson(file: string): any {
|
|
21
29
|
try { return JSON.parse(readFileSync(file, "utf8")); }
|
|
22
30
|
catch { throw new Error(`Cannot read valid JSON: ${file}`); }
|
|
@@ -33,7 +41,7 @@ export function resolveConfig(opts: { cwd?: string; profile?: string; codexBin?:
|
|
|
33
41
|
const configFile = join(cwd, ".agentschat/config.json");
|
|
34
42
|
const project = opts.settings ?? (existsSync(configFile) ? readJson(configFile) : {});
|
|
35
43
|
if (!project || typeof project !== "object" || Array.isArray(project)) throw new Error("Invalid project config");
|
|
36
|
-
const allowed = new Set(["profile", "agent_id", "channels", "senders", "api_url", "ws_url", "permissions"]);
|
|
44
|
+
const allowed = new Set(["profile", "agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort"]);
|
|
37
45
|
if (Object.keys(project).some(k => !allowed.has(k))) throw new Error("Unknown project config field (credentials belong in a private profile)");
|
|
38
46
|
for (const k of ["profile", "agent_id", "api_url", "ws_url"])
|
|
39
47
|
if (project[k] !== undefined && (typeof project[k] !== "string" || !project[k].trim())) throw new Error(`Invalid project ${k}`);
|
|
@@ -84,7 +92,7 @@ export function resolveConfig(opts: { cwd?: string; profile?: string; codexBin?:
|
|
|
84
92
|
}
|
|
85
93
|
const canonicalApi = new URL(apiUrl).href.replace(/\/$/, "");
|
|
86
94
|
const key = createHash("sha256").update(JSON.stringify([cwd, canonicalApi, profile.agent_id])).digest("hex").slice(0, 24);
|
|
87
|
-
return { cwd, profileFile, source, permissions: permissionMode(project.permissions), agentId: profile.agent_id, token: profile.token,
|
|
95
|
+
return { cwd, profileFile, source, permissions: permissionMode(project.permissions), effort: reasoningEffort(project.effort), agentId: profile.agent_id, token: profile.token,
|
|
88
96
|
apiUrl: canonicalApi, wsUrl, channels: strings(project.channels, "channels"),
|
|
89
97
|
senders: strings(project.senders, "senders"), codexBin: opts.codexBin ?? "codex",
|
|
90
98
|
stateDir: join(home, ".agentschat/codex-bridge", key) };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { normalizeTimestampForCursor } from "../src/timestamps.ts";
|
|
4
|
+
|
|
5
|
+
interface Snapshot {
|
|
6
|
+
version: 1;
|
|
7
|
+
channels: Record<string, string>;
|
|
8
|
+
last_check_at?: string;
|
|
9
|
+
last_success_at?: string;
|
|
10
|
+
last_error?: string;
|
|
11
|
+
}
|
|
12
|
+
function timestamp(value: unknown): string {
|
|
13
|
+
if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) throw new Error("Invalid history timestamp");
|
|
14
|
+
return normalizeTimestampForCursor(value, "after")!;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** REST checkpoints, independent of live WS delivery. Advancing a live cursor
|
|
18
|
+
* could otherwise jump over messages that were silently lost before it. */
|
|
19
|
+
export class InboxSync {
|
|
20
|
+
private file: string;
|
|
21
|
+
private state: Snapshot;
|
|
22
|
+
private channels = new Set<string>();
|
|
23
|
+
private running?: Promise<void>;
|
|
24
|
+
constructor(stateDir: string, private api: (path: string) => Promise<any>,
|
|
25
|
+
private receive: (message: unknown) => boolean, private active: () => boolean,
|
|
26
|
+
private log: (message: string) => void = console.error) {
|
|
27
|
+
mkdirSync(stateDir, {recursive:true, mode:0o700});
|
|
28
|
+
this.file = join(stateDir, "inbound-cursors.json");
|
|
29
|
+
this.state = existsSync(this.file) ? JSON.parse(readFileSync(this.file, "utf8")) : {version:1, channels:{}};
|
|
30
|
+
if (this.state.version !== 1 || !this.state.channels || typeof this.state.channels !== "object" || Array.isArray(this.state.channels))
|
|
31
|
+
throw new Error("Invalid inbound cursors; refusing to discard checkpoint");
|
|
32
|
+
for (const value of Object.values(this.state.channels)) timestamp(value);
|
|
33
|
+
this.state.channels = Object.assign(Object.create(null), this.state.channels);
|
|
34
|
+
}
|
|
35
|
+
private save() {
|
|
36
|
+
writeFileSync(this.file + ".tmp", JSON.stringify(this.state), {mode:0o600});
|
|
37
|
+
renameSync(this.file + ".tmp", this.file);
|
|
38
|
+
}
|
|
39
|
+
watch(channel: string) {
|
|
40
|
+
this.channels.add(channel);
|
|
41
|
+
// First installation/new membership starts now, never replays old requests.
|
|
42
|
+
if (!Object.hasOwn(this.state.channels, channel)) {
|
|
43
|
+
this.state.channels[channel] = timestamp(new Date().toISOString()); this.save();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
memberships(channels: string[]) {
|
|
47
|
+
this.channels.clear();
|
|
48
|
+
for (const channel of channels) this.watch(channel);
|
|
49
|
+
}
|
|
50
|
+
sync(): Promise<void> {
|
|
51
|
+
if (!this.running) this.running = this.run().finally(() => {this.running = undefined;});
|
|
52
|
+
return this.running;
|
|
53
|
+
}
|
|
54
|
+
private async run() {
|
|
55
|
+
if (!this.active()) return;
|
|
56
|
+
let failed = false;
|
|
57
|
+
for (const channel of [...this.channels]) {
|
|
58
|
+
if (!this.active()) return;
|
|
59
|
+
try {
|
|
60
|
+
const after = this.state.channels[channel]!;
|
|
61
|
+
const body = await this.api(`/api/channels/${encodeURIComponent(channel)}/messages?after=${encodeURIComponent(after)}&limit=50`);
|
|
62
|
+
if (!this.active()) return;
|
|
63
|
+
if (!this.channels.has(channel)) continue;
|
|
64
|
+
const messages = Array.isArray(body) ? body : body?.messages;
|
|
65
|
+
if (!Array.isArray(messages)) throw new Error("Invalid history response");
|
|
66
|
+
const rows = messages.map(m => {
|
|
67
|
+
if (!m || m.channel_id !== channel || typeof m.id !== "string") throw new Error("Invalid history message");
|
|
68
|
+
return {message:m, time:timestamp(m.timestamp)};
|
|
69
|
+
}).sort((a,b) => a.time.localeCompare(b.time));
|
|
70
|
+
// One bounded page per channel per pass. Full pages continue next pass.
|
|
71
|
+
// The inbox persists acceptance before we persist this checkpoint.
|
|
72
|
+
let newest = after, complete = true;
|
|
73
|
+
for (const {message,time} of rows) {
|
|
74
|
+
if (time <= after) continue;
|
|
75
|
+
if (!this.receive(message)) { complete = false; break; }
|
|
76
|
+
newest = time;
|
|
77
|
+
}
|
|
78
|
+
// Commit whole pages so backpressure cannot skip an equal-time sibling.
|
|
79
|
+
// Already queued rows deduplicate on retry.
|
|
80
|
+
if (complete) { this.state.channels[channel] = newest; this.save(); }
|
|
81
|
+
} catch {
|
|
82
|
+
failed = true;
|
|
83
|
+
this.log("Inbound history reconciliation failed; checkpoint retained for retry");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!this.active()) return;
|
|
87
|
+
this.state.last_check_at = new Date().toISOString();
|
|
88
|
+
if (failed) this.state.last_error = "History reconciliation failed; retry pending";
|
|
89
|
+
else { this.state.last_success_at = this.state.last_check_at; delete this.state.last_error; }
|
|
90
|
+
this.save();
|
|
91
|
+
}
|
|
92
|
+
}
|
package/codex/manager.ts
CHANGED
|
@@ -19,7 +19,7 @@ function removeDeadLock(file: string) {
|
|
|
19
19
|
try { process.kill(pid, 0); } catch (e: any) { if (e.code === "ESRCH") unlinkSync(file); }
|
|
20
20
|
}
|
|
21
21
|
function save() {
|
|
22
|
-
writeFileSync(statusFile + ".tmp", JSON.stringify({ pid: process.pid, updated_at: new Date().toISOString(), bots: [...workers.values()].map(w => ({ name: w.config.name, agent_id: w.config.agentId, workdir: w.config.cwd, pid: w.child?.pid, status: w.status })) }, null, 2), { mode: 0o600 });
|
|
22
|
+
writeFileSync(statusFile + ".tmp", JSON.stringify({ pid: process.pid, updated_at: new Date().toISOString(), bots: [...workers.values()].map(w => ({ name: w.config.name, agent_id: w.config.agentId, workdir: w.config.cwd, effort: w.config.effort, pid: w.child?.pid, status: w.status })) }, null, 2), { mode: 0o600 });
|
|
23
23
|
renameSync(statusFile + ".tmp", statusFile);
|
|
24
24
|
}
|
|
25
25
|
async function stop(w: Worker) {
|
package/codex/run.ts
CHANGED
|
@@ -25,11 +25,11 @@ CWD/.agentschat/profile.json > CWD/.codex/config.toml MCP profile > AGENTSCHAT_P
|
|
|
25
25
|
Only the exact CWD is searched. Named profiles live in ~/.agentschat (legacy ~/.agentchat).
|
|
26
26
|
An optional project agent_id must match the selected profile; it cannot replace it.
|
|
27
27
|
Credentials: private profile JSON {agent_id, token}, chmod 600; never put keys in argv.
|
|
28
|
-
Project config fields: profile, agent_id, channels, senders, api_url, ws_url, permissions.
|
|
28
|
+
Project config fields: profile, agent_id, channels, senders, api_url, ws_url, permissions, effort.
|
|
29
29
|
--onboarding-status checks authentication/ownership and prints safe claim/chat links; it does not send messages.
|
|
30
30
|
--check validates identity and official app-server initialization without opening chat.
|
|
31
31
|
Live DMs and exact mentions trigger replies; channels/senders restrict this further.
|
|
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.
|
|
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. After initial installation, persisted history cursors recover missed messages on reconnect and every minute.
|
|
33
33
|
State: ~/.agentschat/codex-bridge/<project-server-identity hash>/ (private).
|
|
34
34
|
--conversations lists this bot's channel/task mappings; --read-conversation CHANNEL reads history without acquiring a writer.
|
|
35
35
|
GUI outbox: --gui-thread THREAD_ID --gui-message-file PATH; --gui-status lists receipts.
|
|
@@ -65,7 +65,7 @@ async function main() {
|
|
|
65
65
|
}
|
|
66
66
|
console.log(JSON.stringify({ cwd: c.cwd, agent_id: c.agentId, profile: c.profileFile, source: c.source, stateDir: c.stateDir }));
|
|
67
67
|
const runtime = prepareRuntimeHome(c.stateDir);
|
|
68
|
-
codex = new AppServer(c.codexBin, undefined, undefined, c.permissions, runtime);
|
|
68
|
+
codex = new AppServer(c.codexBin, undefined, undefined, c.permissions, runtime, c.effort);
|
|
69
69
|
if (values.conversations || values["read-conversation"]) {
|
|
70
70
|
const state = JSON.parse(readFileSync(join(c.stateDir, "state.json"), "utf8"));
|
|
71
71
|
const channels = state.channels ?? {};
|
|
@@ -79,7 +79,7 @@ async function main() {
|
|
|
79
79
|
codex.close(); return;
|
|
80
80
|
}
|
|
81
81
|
if (values.check) { await codex.start(); console.log("Official app-server initialization: OK (no chat connection or generation)"); codex.close(); return; }
|
|
82
|
-
transport = new AgentsChatTransport(c, m => { bridge!.accept(m); });
|
|
82
|
+
transport = new AgentsChatTransport(c, m => { bridge!.accept(m); }, console.error, m => bridge!.recover(m));
|
|
83
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"));
|
|
84
84
|
let stopping = false;
|
|
85
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?.(); };
|
package/codex/transport.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import WebSocket from "ws";
|
|
3
3
|
import type { BridgeConfig } from "./config.ts";
|
|
4
4
|
import { HeartbeatMonitor } from "../src/heartbeat.ts";
|
|
5
|
+
import { InboxSync } from "./inbox-sync.ts";
|
|
5
6
|
|
|
6
7
|
export class AgentsChatTransport {
|
|
7
8
|
private socket?: WebSocket;
|
|
@@ -12,7 +13,13 @@ export class AgentsChatTransport {
|
|
|
12
13
|
private retry?: ReturnType<typeof setTimeout>;
|
|
13
14
|
private closed = false;
|
|
14
15
|
private delay = 1000;
|
|
15
|
-
|
|
16
|
+
private inboxSync?: InboxSync;
|
|
17
|
+
private syncTimer?: ReturnType<typeof setInterval>;
|
|
18
|
+
constructor(private config: BridgeConfig, private receive: (m: unknown) => void, private log: (s: string) => void = console.error,
|
|
19
|
+
recover?: (m: unknown) => boolean, private syncIntervalMs = 60_000) {
|
|
20
|
+
if (recover) this.inboxSync = new InboxSync(config.stateDir, path => this.api(path), recover,
|
|
21
|
+
() => !this.closed && this.authenticated, log);
|
|
22
|
+
}
|
|
16
23
|
async api(path: string, body?: unknown) {
|
|
17
24
|
let response: Response;
|
|
18
25
|
try {
|
|
@@ -62,7 +69,10 @@ export class AgentsChatTransport {
|
|
|
62
69
|
const current = () => !this.closed && this.socket === socket;
|
|
63
70
|
const send = (value: unknown) => { if (current() && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(value)); };
|
|
64
71
|
const join = (channel: string) => {
|
|
65
|
-
if (!this.config.channels.length || this.config.channels.includes(channel))
|
|
72
|
+
if (!this.config.channels.length || this.config.channels.includes(channel)) {
|
|
73
|
+
this.inboxSync?.watch(channel);
|
|
74
|
+
send({ type: "join_channel", channel_id: channel, agent_id: this.config.agentId });
|
|
75
|
+
}
|
|
66
76
|
};
|
|
67
77
|
this.heartbeat = new HeartbeatMonitor({ getReadyState: () => socket.readyState,
|
|
68
78
|
sendPing: () => send({ type: "ping" }), reconnect: () => socket.terminate() }, 15_000, 45_000, 30_000);
|
|
@@ -74,26 +84,31 @@ export class AgentsChatTransport {
|
|
|
74
84
|
this.heartbeat?.receivedPong();
|
|
75
85
|
if (data.type === "auth_ok") {
|
|
76
86
|
this.authenticated = true; this.delay = 1000; this.log(`AgentsChat connected as ${this.config.agentId}`);
|
|
87
|
+
if (this.inboxSync && !this.syncTimer) this.syncTimer = setInterval(() => { void this.inboxSync!.sync(); }, this.syncIntervalMs);
|
|
77
88
|
void this.api("/api/channels/mine").then((body: any) => {
|
|
78
89
|
if (!current()) return;
|
|
79
90
|
const channels = Array.isArray(body) ? body : body.channels;
|
|
80
91
|
if (!Array.isArray(channels)) throw new Error("Invalid membership response");
|
|
81
|
-
|
|
92
|
+
const ids = channels.map((c: any) => c.id ?? c.channel_id).filter((id: any) => typeof id === "string" &&
|
|
93
|
+
(!this.config.channels.length || this.config.channels.includes(id)));
|
|
94
|
+
this.inboxSync?.memberships(ids);
|
|
95
|
+
for (const id of ids) join(id);
|
|
96
|
+
void this.inboxSync?.sync();
|
|
82
97
|
}).catch(() => { if (current()) { this.log("Membership sync failed; reconnecting"); socket.terminate(); } });
|
|
83
98
|
} else if (data.type === "message_ack") {
|
|
84
99
|
const id = data.message_id ?? data.id, pending = this.pending.get(id);
|
|
85
100
|
if (pending) { clearTimeout(pending.timer); this.pending.delete(id); pending.resolve(); }
|
|
86
101
|
} else if (data.type === "channel_created" && typeof data.channel_id === "string") join(data.channel_id);
|
|
87
102
|
else if (["message", "thread_reply"].includes(data.type)) this.receive(data);
|
|
88
|
-
else if (data.type === "shard_moved") socket.terminate();
|
|
103
|
+
else if (data.type === "shard_moved" || data.type === "please_reconnect") socket.terminate();
|
|
89
104
|
else if (data.type === "auth_error" || data.type === "error") this.log("AgentsChat returned an error; check account and channel permissions");
|
|
90
105
|
});
|
|
91
106
|
socket.on("error", () => this.log("AgentsChat socket error"));
|
|
92
107
|
socket.on("close", () => {
|
|
93
108
|
if (!current()) return;
|
|
94
|
-
this.authenticated = false; this.rejectPending(); this.heartbeat?.stop(); this.log("AgentsChat disconnected; reconnecting
|
|
109
|
+
this.authenticated = false; this.rejectPending(); this.heartbeat?.stop(); this.log("AgentsChat disconnected; reconnecting with history reconciliation");
|
|
95
110
|
this.retry = setTimeout(() => this.start(), this.delay); this.delay = Math.min(this.delay * 2, 30_000);
|
|
96
111
|
});
|
|
97
112
|
}
|
|
98
|
-
stop() { for (const timer of this.typing.values()) clearInterval(timer); this.typing.clear(); this.closed = true; this.authenticated = false; this.rejectPending(); clearTimeout(this.retry); this.heartbeat?.stop(); this.socket?.terminate(); }
|
|
113
|
+
stop() { for (const timer of this.typing.values()) clearInterval(timer); this.typing.clear(); this.closed = true; this.authenticated = false; this.rejectPending(); clearTimeout(this.retry); clearInterval(this.syncTimer); this.heartbeat?.stop(); this.socket?.terminate(); }
|
|
99
114
|
}
|
package/dist/codex-bots.js
CHANGED
|
@@ -904,6 +904,13 @@ function parse(toml, { maxDepth = 1000, integersAsBigInt } = {}) {
|
|
|
904
904
|
*/
|
|
905
905
|
|
|
906
906
|
// codex/config.ts
|
|
907
|
+
function reasoningEffort(value) {
|
|
908
|
+
if (value === undefined)
|
|
909
|
+
return;
|
|
910
|
+
if (typeof value !== "string" || !value.trim())
|
|
911
|
+
throw new Error("effort must be a nonempty model-supported reasoning effort name");
|
|
912
|
+
return value.trim();
|
|
913
|
+
}
|
|
907
914
|
function permissionMode(value) {
|
|
908
915
|
if (value === undefined)
|
|
909
916
|
return "full-access";
|
|
@@ -931,7 +938,7 @@ function resolveConfig(opts, env = process.env, home = homedir()) {
|
|
|
931
938
|
const project = opts.settings ?? (existsSync(configFile) ? readJson(configFile) : {});
|
|
932
939
|
if (!project || typeof project !== "object" || Array.isArray(project))
|
|
933
940
|
throw new Error("Invalid project config");
|
|
934
|
-
const allowed = new Set(["profile", "agent_id", "channels", "senders", "api_url", "ws_url", "permissions"]);
|
|
941
|
+
const allowed = new Set(["profile", "agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort"]);
|
|
935
942
|
if (Object.keys(project).some((k) => !allowed.has(k)))
|
|
936
943
|
throw new Error("Unknown project config field (credentials belong in a private profile)");
|
|
937
944
|
for (const k of ["profile", "agent_id", "api_url", "ws_url"])
|
|
@@ -1001,6 +1008,7 @@ function resolveConfig(opts, env = process.env, home = homedir()) {
|
|
|
1001
1008
|
profileFile,
|
|
1002
1009
|
source,
|
|
1003
1010
|
permissions: permissionMode(project.permissions),
|
|
1011
|
+
effort: reasoningEffort(project.effort),
|
|
1004
1012
|
agentId: profile.agent_id,
|
|
1005
1013
|
token: profile.token,
|
|
1006
1014
|
apiUrl: canonicalApi,
|
|
@@ -1040,7 +1048,7 @@ function loadBots(file = defaultRegistry(), home = homedir2()) {
|
|
|
1040
1048
|
const names = new Set, identities = new Set;
|
|
1041
1049
|
const bots = [];
|
|
1042
1050
|
for (const bot of doc.bots) {
|
|
1043
|
-
fields(bot, ["name", "profile", "workdir", "enabled", "agent_id", "channels", "senders", "api_url", "ws_url", "permissions"]);
|
|
1051
|
+
fields(bot, ["name", "profile", "workdir", "enabled", "agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort"]);
|
|
1044
1052
|
if (!text(bot.name) || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(bot.name) || names.has(bot.name))
|
|
1045
1053
|
throw new Error("Bot names must be unique simple labels");
|
|
1046
1054
|
names.add(bot.name);
|
|
@@ -1056,7 +1064,7 @@ function loadBots(file = defaultRegistry(), home = homedir2()) {
|
|
|
1056
1064
|
mkdirSync(defaultDir, { recursive: true, mode: 448 });
|
|
1057
1065
|
const cwd = realpathSync2(bot.workdir ? path(bot.workdir) : defaultDir);
|
|
1058
1066
|
const settings = {};
|
|
1059
|
-
for (const k of ["agent_id", "channels", "senders", "api_url", "ws_url", "permissions"])
|
|
1067
|
+
for (const k of ["agent_id", "channels", "senders", "api_url", "ws_url", "permissions", "effort"])
|
|
1060
1068
|
if (bot[k] !== undefined)
|
|
1061
1069
|
settings[k] = bot[k];
|
|
1062
1070
|
const config = resolveConfig({ cwd, profile: bot.profile, settings, codexBin: doc.codex_bin }, {}, home);
|
|
@@ -1122,7 +1130,7 @@ function removeDeadLock(file) {
|
|
|
1122
1130
|
}
|
|
1123
1131
|
}
|
|
1124
1132
|
function save() {
|
|
1125
|
-
writeFileSync(statusFile + ".tmp", JSON.stringify({ pid: process.pid, updated_at: new Date().toISOString(), bots: [...workers.values()].map((w) => ({ name: w.config.name, agent_id: w.config.agentId, workdir: w.config.cwd, pid: w.child?.pid, status: w.status })) }, null, 2), { mode: 384 });
|
|
1133
|
+
writeFileSync(statusFile + ".tmp", JSON.stringify({ pid: process.pid, updated_at: new Date().toISOString(), bots: [...workers.values()].map((w) => ({ name: w.config.name, agent_id: w.config.agentId, workdir: w.config.cwd, effort: w.config.effort, pid: w.child?.pid, status: w.status })) }, null, 2), { mode: 384 });
|
|
1126
1134
|
renameSync(statusFile + ".tmp", statusFile);
|
|
1127
1135
|
}
|
|
1128
1136
|
async function stop(w) {
|