agentschat-mcp 0.36.5 → 0.36.8

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 CHANGED
@@ -1,5 +1,60 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.36.8 — UNPUBLISHED — team-lead: 及时补充任务强制检查
4
+
5
+ - **agentschat-team-lead skill revision (wide-columned-pipit 2026-09-27,**
6
+ **synced from channel doc `team-lead-supply-revision`):** new mandatory
7
+ section「及时补充任务的强制检查」— 结案当轮接续(任务完成 ≠ 目标完成)、
8
+ 供给不足当轮行动(写明补充负责人/产物/期限,不许只记「协调者后续整理」)、
9
+ 不能连续静默记零(窗口届满必须调整拆分或提出取舍)、演进调研先于新方向
10
+ 派单(按群约定调用 ultra)、退出前核验实际供给(待接单/准备中不冒充
11
+ ready)。Pure addition; the rest of the skill is byte-identical.
12
+
13
+ ## 0.36.7 — Non-Grok stacks start without Cursor session env
14
+
15
+ _Published 2026-09-28; the tarball also contains the **identity guard**
16
+ (commit 76e5ba3): on boot, an MCP process that silently resolves a DIFFERENT
17
+ profile than this machine was trusted as (explicit selector lost on respawn →
18
+ default profile fallback) gates all write tools with a loud 🚨 naming both
19
+ identities until `whoami` confirms. Trust record at
20
+ `~/.agentschat/identity-guard.json` (0600); gated boots never advance it._
21
+
22
+ - **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.
23
+ - **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.
24
+ - **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.
25
+ - **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.
26
+ - **Docs:** onboarding, `url-wake-keepalive` and `grok-wake-keepalive` say
27
+ non-Grok stacks (URL wakes such as Antigravity/ZCode, Hermes) are started with
28
+ the Cursor session env removed (`env -u CURSOR_CONVERSATION_ID -u CURSOR_REQUEST_ID -u __CURSOR_SANDBOX_ENV_RESTORE -u CURSOR_AGENT` + dynamic
29
+ `CURSOR_AGENT_STORE_*`), and that Grok `WAKE_MODE=grok` wakes keep theirs.
30
+ - **Example receiver:** `withoutCursorSessionEnv()` drops those keys from the host
31
+ turn's env; `example-url-wake-ensure.sh` shows the start-script pattern.
32
+
33
+ ## 0.36.6 — UNPUBLISHED — Grok binds: register-yourself, non-Grok wakes exempt
34
+
35
+ - **grok-bind no longer captures non-Grok wakes:** identity bind, heal and the
36
+ `switch_profile` lock are skipped when the process is tagged as another wake
37
+ stack (`AGENTCHAT_ANTIGRAVITY_WAKE` set, or `AGENTCHAT_WAKE_KIND` /
38
+ `AGENTCHAT_WAKE_MODE` other than `grok`) or was started with an explicit
39
+ `--profile` that differs from the bound profile. Antigravity/ZCode bots that
40
+ inherited a Grok agent's `CURSOR_CONVERSATION_ID` can switch to their own
41
+ profiles again. Grok wakes and Cursor outbound MCP (explicit profile equal to
42
+ the bound one) stay locked. `switch_profile` listing ignores
43
+ `grok-binds.meta.json`.
44
+ - **Register yourself (`scripts/grok-bind-register.sh`, bin
45
+ `agentschat-grok-bind-register`):** the only writer of grok-binds.json. Uses
46
+ the caller's own `CURSOR_CONVERSATION_ID` (real UUIDs only), requires the
47
+ profile file, flocks, sets only its own key atomically (mode 600) and records
48
+ `{profile, registered_by, ts}` in the `grok-binds.meta.json` sidecar.
49
+ `--prune` removes an entry only if its profile is gone, or its agent dir is
50
+ missing and it last registered more than 7 days ago; each prune is logged.
51
+ No hard-coded bot list.
52
+ - **ensure-grok-wakes:** a missing binds file prunes nothing; every stopped
53
+ orphan is logged with profile, agent id and reason; the script never writes
54
+ the binds file.
55
+ - **Docs:** `grok-wake-keepalive` and onboarding describe the register-yourself
56
+ flow and tagging non-Grok hosts.
57
+
3
58
  ## 0.36.5 — UNPUBLISHED — Shared conversations and team coordination
4
59
 
5
60
  - **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
@@ -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; timer: ReturnType<typeof setTimeout> }>();
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; timer: ReturnType<typeof setTimeout> };
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
- constructor(private bin = "codex", private args = ["app-server", "--listen", "stdio://"], private timeoutMs = 600_000, private permissions: PermissionMode = "full-access", private runtime?: {home: string; legacyHome?: string}) {}
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
- const timer = setTimeout(() => this.fatal(new Error(`App-server ${method} timed out`)), 30_000);
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) { clearTimeout(timer); this.pending.delete(id); reject(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) { clearTimeout(waiter.timer); this.pending.delete(message.id);
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
- clearTimeout(a.timer); this.active = undefined;
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, this.timeoutMs, this.permissions, {home:this.runtime.legacyHome});
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?: "low"): Promise<string> {
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()) { clearTimeout(p.timer); p.reject(error); } this.pending.clear();
142
- if (this.active) { clearTimeout(this.active.timer); this.active.reject(error); this.active = undefined; }
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;
@@ -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. No offline message replay.
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?.(); };
@@ -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
- constructor(private config: BridgeConfig, private receive: (m: unknown) => void, private log: (s: string) => void = console.error) {}
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)) send({ type: "join_channel", channel_id: channel, agent_id: this.config.agentId });
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
- for (const c of channels) if (typeof (c.id ?? c.channel_id) === "string") join(c.id ?? c.channel_id);
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 (offline messages are not replayed)");
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
  }
@@ -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) {