agentschat-mcp 0.36.4 → 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/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";
5
+ import { priorChannelThreads, preserveChannelHistory, type ThreadHistory } from "./thread-history.ts";
3
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 State { version: 1; threads: Record<string, string>; entries: Entry[] }
9
- export interface Generator { thread(cwd: string, existing?: string, ephemeral?: boolean, permissions?: PermissionMode): Promise<string>; generate(thread: string, prompt: string): Promise<string> }
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.sender_id === c.agentId || m.content.length > 32_000) return false;
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,11 +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
40
  private activity: (channel: string, active: boolean) => void = () => {},
31
- private owner: () => Promise<string | null> = async () => null) {
41
+ private owner: () => Promise<string | null> = async () => null,
42
+ private loops: () => Promise<unknown> = async () => null) {
32
43
  mkdirSync(config.stateDir, { recursive: true, mode: 0o700 });
33
44
  this.file = join(config.stateDir, "state.json"); this.lock = join(config.stateDir, "bridge.lock");
34
45
  try { const fd = openSync(this.lock, "wx", 0o600); writeFileSync(fd, String(process.pid)); closeSync(fd); }
@@ -50,20 +61,67 @@ export class Bridge {
50
61
  accept(raw: unknown): boolean {
51
62
  if (this.stopped || !addressed(raw, this.config)) return false;
52
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;
53
66
  if (this.state.entries.filter(e => ["pending", "running", "ready", "sending"].includes(e.status)).length >= 100) {
54
67
  this.log("Inbox full; message not accepted"); return false;
55
68
  }
56
69
  // Only retain the wire fields used by this bridge; no protocol instructions.
57
- const message = { id: raw.id, channel_id: raw.channel_id, sender_id: raw.sender_id, content: this.redact(raw.content) };
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 } : {}) };
58
75
  this.state.entries.push({ message, status: "pending" }); this.save();
59
76
  void this.drain(); return true;
60
77
  }
61
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
+ }
62
88
  drain(): Promise<void> {
89
+ if (this.retry) return Promise.resolve();
63
90
  if (this.draining) return this.draining;
64
91
  this.draining = this.run().finally(() => { this.draining = undefined; });
65
92
  return this.draining;
66
93
  }
94
+ /** Can be called while idle to migrate existing channels without sending messages. */
95
+ async prepareChannel(chat: string): Promise<ChannelThread> {
96
+ this.state.channels ??= {};
97
+ let channel = this.state.channels[chat];
98
+ if (!this.loaded.has(chat)) {
99
+ const namespace = this.codex.namespace;
100
+ if (channel?.namespace && channel.namespace !== namespace)
101
+ throw new Error("Conversation belongs to a different runtime home; refusing to lose context");
102
+ const movingHome = !!namespace && !!channel && !channel.namespace;
103
+ if (!channel || movingHome) {
104
+ const ids = [...new Set([...priorChannelThreads(this.state.threads, chat), ...(movingHome ? [channel!.thread] : [])])];
105
+ const histories: ThreadHistory[] = [];
106
+ for (const id of ids) {
107
+ const reader = namespace ? this.codex.readLegacyThread : this.codex.readThread;
108
+ if (!reader) throw new Error("Cannot migrate channel without original thread history");
109
+ histories.push(await reader.call(this.codex, id));
110
+ }
111
+ const bootstrap = histories.length ? preserveChannelHistory(this.config.stateDir, chat, histories, s => this.redact(s)) : undefined;
112
+ const thread = await this.codex.thread(this.config.cwd, undefined, false, this.config.permissions);
113
+ channel = this.state.channels[chat] = {thread, ...(namespace ? {namespace} : {}), ...(bootstrap ? {bootstrap, importedThreads:ids} : {})};
114
+ this.save();
115
+ // Stable titles make read-only conversation listings useful.
116
+ await this.codex.nameThread?.(thread, `AgentsChat · ${chat}`).catch(() => this.log("Could not name channel task"));
117
+ } else {
118
+ channel.thread = await this.codex.thread(this.config.cwd, channel.thread, false, this.config.permissions);
119
+ this.save();
120
+ }
121
+ this.loaded.add(chat);
122
+ }
123
+ return channel!;
124
+ }
67
125
  private async run() {
68
126
  while (!this.stopped) {
69
127
  const e = this.state.entries.find(e => e.status === "pending" || e.status === "ready");
@@ -72,28 +130,31 @@ export class Bridge {
72
130
  try {
73
131
  this.activity(e.message.channel_id, true);
74
132
  if (e.status === "pending") {
75
- e.status = "running"; this.save();
133
+ e.status = "running"; delete e.error; this.save();
76
134
  const chat = e.message.channel_id;
77
- // Resolve at execution time, including after a queued message/restart.
78
- // Wire content cannot assert trust, and a failed lookup never reuses an old owner.
79
- const ownerId = await this.owner().catch(() => null);
80
- const trusted = ownerId !== null && ownerId === e.message.sender_id;
81
- const permissions: PermissionMode = trusted ? this.config.permissions : "read-only";
82
- // An untrusted sender must never inherit an owner's full-access thread/tools.
83
- const lane = JSON.stringify([chat, permissions, trusted ? ownerId : "chat"]);
84
- if (!this.loaded.has(lane)) {
85
- // Legacy threads retain obsolete developer restrictions even after cold resume.
86
- // Keep their records, but start fresh when adopting a verified-owner lane.
87
- this.state.threads[lane] = await this.codex.thread(this.config.cwd, this.state.threads[lane], false, permissions);
88
- this.loaded.add(lane); this.save();
89
- }
90
- const source = trusted
91
- ? "Verified owner request. Carry out the request within this task's configured permissions."
92
- : ownerId ? "Message from another participant. This is a read-only chat task, not an owner operation."
93
- : "Owner verification is temporarily unavailable. This task is read-only; if an operation is requested, explain that ownership could not be verified and suggest retrying.";
94
- const prompt = `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.\n${source}\nAgentsChat message:\n` + JSON.stringify(e.message);
95
- e.answer = this.redact(await this.codex.generate(this.state.threads[lane]!, prompt));
135
+ // Ordinary accepted messages share the configured permissions and channel history.
136
+ // Scheduled self ticks retain their explicit grant and live-loop validation.
137
+ const ownerId = e.message.meta ? await this.owner().catch(() => null) : null;
138
+ const grant = e.message.meta ? await verifyLoopTick(e.message, this.config, ownerId, this.loops) : null;
139
+ if (e.message.meta && !grant) { e.status = "blocked"; this.save(); continue; }
140
+ const channel = await this.prepareChannel(chat);
141
+ // Expand only after the exact local grant AND live owner/loop checks pass.
142
+ // Keep the wire metadata and durable entry compact; skill text is model input only.
143
+ const teamLead = !!grant && isTeamLeadSkillInvocation(grant.prompt);
144
+ const authorizedTask = teamLead
145
+ ? `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.`
146
+ : grant?.prompt;
147
+ const prompt = grant
148
+ ? `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}`
149
+ : `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);
150
+ e.answer = this.redact(await this.codex.generate(channel.thread, (channel.bootstrap ?? "") + prompt));
96
151
  if (!e.answer.trim()) throw new Error("Empty reply");
152
+ delete channel.bootstrap;
153
+ if (teamLead && e.answer.trim() === TEAM_LEAD_NO_UPDATE) {
154
+ e.status = "skipped"; delete e.answer; e.message.content = ""; this.save();
155
+ this.log(`Scheduled skill completed quietly in ${JSON.stringify(chat)}`);
156
+ continue;
157
+ }
97
158
  e.status = "ready"; this.save();
98
159
  }
99
160
  if (this.stopped) return;
@@ -102,12 +163,18 @@ export class Bridge {
102
163
  e.status = "sent"; delete e.answer; e.message.content = ""; this.save();
103
164
  this.log(`Replied in ${JSON.stringify(e.message.channel_id)}`);
104
165
  } catch (error) {
166
+ if (error instanceof ThreadBusyError) {
167
+ e.status = "pending"; e.error = error.message; this.save();
168
+ this.log("Conversation in use; queued message will resume in the same task");
169
+ if (!this.stopped) this.retry = setTimeout(() => { this.retry = undefined; void this.drain(); }, 5000);
170
+ return;
171
+ }
105
172
  e.error = this.redact(error instanceof Error ? error.message : "Bridge operation failed").slice(0, 240);
106
173
  e.status = e.status === "sending" ? "uncertain" : "failed";
107
174
  this.save(); this.log(`Message ${JSON.stringify(e.message.id)} ${e.status}; inspect private state before retrying`);
108
175
  } finally { this.activity(e.message.channel_id, false); }
109
176
  }
110
177
  }
111
- pause() { this.stopped = true; }
178
+ pause() { this.stopped = true; clearTimeout(this.retry); this.retry = undefined; }
112
179
  async stop() { this.pause(); await this.draining; if (existsSync(this.lock)) unlinkSync(this.lock); }
113
180
  }
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
+ }
@@ -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/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
@@ -1,3 +1,5 @@
1
+ import { prepareRuntimeHome } from "./runtime-home.ts";
2
+ import { redactSecrets } from "../src/redact.ts";
1
3
  import { getBotOwner } from "./owner.ts";
2
4
  import { getOnboardingStatus } from "../src/onboarding-status.ts";
3
5
  import { GuiChannel } from "./gui-channel.ts";
@@ -15,7 +17,7 @@ import { AgentsChatTransport } from "./transport.ts";
15
17
  export const HELP = `agentschat-mcp --codex-bridge [--cwd DIRECTORY] [--profile NAME_OR_PATH] [--codex-bin PATH] [--check]
16
18
 
17
19
  Official Codex app-server bridge. Node >=22; Codex installed and signed in.
18
- Starts a dedicated stdio app-server; does not attach to an active desktop task.
20
+ Starts a dedicated stdio app-server with a private per-bot Codex home; does not attach to an active desktop task.
19
21
  No notifications/chat/channel, no fork, no account registration.
20
22
 
21
23
  Identity: --profile > CWD/.agentschat/config.json profile >
@@ -23,12 +25,13 @@ CWD/.agentschat/profile.json > CWD/.codex/config.toml MCP profile > AGENTSCHAT_P
23
25
  Only the exact CWD is searched. Named profiles live in ~/.agentschat (legacy ~/.agentchat).
24
26
  An optional project agent_id must match the selected profile; it cannot replace it.
25
27
  Credentials: private profile JSON {agent_id, token}, chmod 600; never put keys in argv.
26
- 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.
27
29
  --onboarding-status checks authentication/ownership and prints safe claim/chat links; it does not send messages.
28
30
  --check validates identity and official app-server initialization without opening chat.
29
31
  Live DMs and exact mentions trigger replies; channels/senders restrict this further.
30
- Server-verified owner requests use full access by default; other senders stay read-only. 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.
31
33
  State: ~/.agentschat/codex-bridge/<project-server-identity hash>/ (private).
34
+ --conversations lists this bot's channel/task mappings; --read-conversation CHANNEL reads history without acquiring a writer.
32
35
  GUI outbox: --gui-thread THREAD_ID --gui-message-file PATH; --gui-status lists receipts.
33
36
  Requires an authorized GUI host to dispatch; enqueue alone does not wake a task.
34
37
  See codex/README.md for setup, verification, limitations and recovery.
@@ -37,7 +40,7 @@ See codex/README.md for setup, verification, limitations and recovery.
37
40
  let codex: AppServer | undefined, bridge: Bridge | undefined, transport: AgentsChatTransport | undefined;
38
41
  async function main() {
39
42
  const { values } = parseArgs({ options: { "codex-bridge": { type: "boolean" }, cwd: { type: "string" },
40
- "gui-thread": { type: "string" }, "gui-message-file": { type: "string" }, "gui-status": { type: "boolean" }, "managed-worker": { type: "boolean" }, registry: { type: "string" }, bot: { type: "string" }, profile: { type: "string" }, "codex-bin": { type: "string" }, check: { type: "boolean" }, "onboarding-status": { type: "boolean" },
43
+ "conversations": { type: "boolean" }, "read-conversation": { type: "string" }, "gui-thread": { type: "string" }, "gui-message-file": { type: "string" }, "gui-status": { type: "boolean" }, "managed-worker": { type: "boolean" }, registry: { type: "string" }, bot: { type: "string" }, profile: { type: "string" }, "codex-bin": { type: "string" }, check: { type: "boolean" }, "onboarding-status": { type: "boolean" },
41
44
  help: { type: "boolean", short: "h" } }, strict: true });
42
45
  if (values.help) { console.log(HELP); return; }
43
46
  if (values["gui-thread"] || values["gui-message-file"] || values["gui-status"]) {
@@ -61,10 +64,23 @@ async function main() {
61
64
  return;
62
65
  }
63
66
  console.log(JSON.stringify({ cwd: c.cwd, agent_id: c.agentId, profile: c.profileFile, source: c.source, stateDir: c.stateDir }));
64
- codex = new AppServer(c.codexBin, undefined, undefined, c.permissions);
67
+ const runtime = prepareRuntimeHome(c.stateDir);
68
+ codex = new AppServer(c.codexBin, undefined, undefined, c.permissions, runtime, c.effort);
69
+ if (values.conversations || values["read-conversation"]) {
70
+ const state = JSON.parse(readFileSync(join(c.stateDir, "state.json"), "utf8"));
71
+ const channels = state.channels ?? {};
72
+ if (values.conversations) { console.log(JSON.stringify(Object.entries(channels).map(([channel, data]: [string, any]) => ({channel, thread:data.thread, isolated:data.namespace===runtime.home})))); return; }
73
+ const channel = channels[values["read-conversation"]!];
74
+ if (!channel) throw new Error("Unknown conversation channel");
75
+ if (channel.namespace && channel.namespace !== runtime.home) throw new Error("Conversation belongs to a different runtime home");
76
+ await codex.start();
77
+ const history = channel.namespace ? await codex.readThread(channel.thread) : await codex.readLegacyThread(channel.thread);
78
+ console.log(redactSecrets(JSON.stringify(history).split(c.token).join("[REDACTED]")));
79
+ codex.close(); return;
80
+ }
65
81
  if (values.check) { await codex.start(); console.log("Official app-server initialization: OK (no chat connection or generation)"); codex.close(); return; }
66
- transport = new AgentsChatTransport(c, m => { bridge!.accept(m); });
67
- bridge = new Bridge(c, codex, (chat, text) => transport!.send(chat, text), console.error, (chat, active) => transport!.setTyping(chat, active), () => getBotOwner(c.apiUrl, c.agentId, c.token));
82
+ transport = new AgentsChatTransport(c, m => { bridge!.accept(m); }, console.error, m => bridge!.recover(m));
83
+ bridge = new Bridge(c, codex, (chat, text) => transport!.send(chat, text), console.error, (chat, active) => transport!.setTyping(chat, active), () => getBotOwner(c.apiUrl, c.agentId, c.token), () => transport!.api("/api/loops/mine"));
68
84
  let stopping = false;
69
85
  const stop = async () => { if (stopping) return; stopping = true; if (values["managed-worker"]) { const deadline = setTimeout(() => { try { process.kill(-process.pid, "SIGKILL"); } catch {} }, 20000); deadline.unref(); } bridge?.pause(); transport?.stop(); codex?.close(); await bridge?.stop(); if (process.connected) process.disconnect?.(); };
70
86
  codex.onFatal = () => { console.error("Codex backend stopped; pending inbox preserved. Restart the bridge after checking failed entries."); process.exitCode = 1; void stop(); };
@@ -0,0 +1,22 @@
1
+ import { existsSync, mkdirSync, lstatSync, readlinkSync, realpathSync, symlinkSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+
5
+ /** Reuse operator configuration, never the desktop's databases, sessions or writer locks. */
6
+ export function prepareRuntimeHome(stateDir: string, source = process.env.CODEX_HOME || join(homedir(), ".codex")) {
7
+ const legacyHome = existsSync(source) ? realpathSync(source) : resolve(source);
8
+ const home = join(stateDir, "codex-home");
9
+ mkdirSync(home, {recursive:true, mode:0o700});
10
+ if (realpathSync(home) === legacyHome) throw new Error("Bot runtime home must differ from desktop Codex home");
11
+ for (const name of ["config.toml", "auth.json", "AGENTS.md", "skills", "rules", "plugins", "hooks.json", "models_cache.json"]) {
12
+ const origin = join(legacyHome, name), target = join(home, name);
13
+ if (!existsSync(origin)) continue;
14
+ let current;
15
+ try { current = lstatSync(target); } catch (e: any) { if (e.code !== "ENOENT") throw e; }
16
+ if (current) {
17
+ if (!current.isSymbolicLink() || resolve(home, readlinkSync(target)) !== origin)
18
+ throw new Error(`Unexpected bot runtime configuration: ${name}`);
19
+ } else symlinkSync(origin, target);
20
+ }
21
+ return {home, legacyHome};
22
+ }
@@ -0,0 +1,66 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ export interface ThreadHistory { id: string; createdAt?: number; turns: any[] }
6
+ export function priorChannelThreads(threads: Record<string, string>, channel: string): string[] {
7
+ return [...new Set(Object.entries(threads).filter(([key]) => {
8
+ if (key === channel) return true;
9
+ try { const parts = JSON.parse(key); return Array.isArray(parts) && parts[0] === channel; }
10
+ catch { return false; }
11
+ }).map(([, id]) => id))];
12
+ }
13
+
14
+ /** Keep complete source turns privately; seed the new task with past chat, not old instructions. */
15
+ export function preserveChannelHistory(directory: string, channel: string, histories: ThreadHistory[], redact: (s: string) => string): string {
16
+ const root = join(directory, "history"); mkdirSync(root, {recursive:true, mode:0o700});
17
+ const file = join(root, createHash("sha256").update(channel).digest("hex").slice(0,24) + ".json");
18
+ writeFileSync(file + ".tmp", redact(JSON.stringify({channel_id:channel, threads:histories})), {mode:0o600});
19
+ renameSync(file + ".tmp", file);
20
+ const records: { order: string; sequence: number; value: any }[] = [];
21
+ let sequence = 0;
22
+ for (const history of histories) for (const turn of history.turns) {
23
+ // Codex turn IDs are UUIDv7 and sort by creation time; keep item order within a turn.
24
+ const order = String(turn.id ?? history.createdAt ?? history.id);
25
+ for (const item of turn.items ?? []) {
26
+ if (item.type === "userMessage") {
27
+ let text = (item.content ?? []).filter((part: any) => part.type === "text").map((part: any) => part.text).join("\n");
28
+ const marker = "AgentsChat message:\n";
29
+ if (text.includes(marker)) {
30
+ try {
31
+ const message = JSON.parse(text.slice(text.lastIndexOf(marker) + marker.length));
32
+ if (message.channel_id === channel) records.push({order,sequence:sequence++,value:{...message,role:"user"}});
33
+ continue;
34
+ } catch { /* Older bridges used plain-text envelopes; retain them below. */ }
35
+ }
36
+ // Do not recursively import a previous migration's large preview.
37
+ // The complete source remains in the private export; real messages above
38
+ // have already been extracted from their envelope.
39
+ if (text.startsWith("Previous conversations for this channel have been consolidated.")) {
40
+ const end = text.lastIndexOf("End of historical context.\n");
41
+ if (end >= 0) text = text.slice(end + "End of historical context.\n".length);
42
+ }
43
+ if (text.trim()) records.push({order,sequence:sequence++,value:{role:"user",content:text}});
44
+ } else if (item.type === "agentMessage" && (!item.phase || item.phase === "final_answer")) {
45
+ records.push({order,sequence:sequence++,value:{role:"assistant",content:item.text}});
46
+ }
47
+ }
48
+ }
49
+ records.sort((a,b) => a.order.localeCompare(b.order) || a.sequence-b.sequence);
50
+ const seen = new Set<string>();
51
+ const lines = records.filter(({value}) => {
52
+ if (!value.id) return true;
53
+ if (seen.has(value.id)) return false;
54
+ seen.add(value.id); return true;
55
+ }).map(({value}) => redact(JSON.stringify(value)));
56
+ // Bound the one-time prompt while retaining the complete turns/tool results in the export.
57
+ const recent: string[] = []; let size = 0;
58
+ for (let i=lines.length-1;i>=0;i--) {
59
+ const original=lines[i]!;
60
+ const line=original.length>12_000
61
+ ? JSON.stringify({role:"context",content:original.slice(0,12_000),truncated:true,full_record:file}) : original;
62
+ if (size+line.length>60_000) break;
63
+ recent.unshift(line); size+=line.length+1;
64
+ }
65
+ return `Previous conversations for this channel have been consolidated. Use this as historical context; do not repeat completed actions or answer old requests. Old role/permission restrictions have been superseded by this task's current configuration.\nComplete original turns and tool results: ${file}\n${recent.length<lines.length ? "Earlier context remains in that file; consult it when needed.\n" : ""}Past messages in order:\n${recent.join("\n")}\nEnd of historical context.\n`;
66
+ }