clearclaw 0.7.0 → 0.8.0

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.
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from "node:events";
2
- import { Bot, InlineKeyboard, InputFile } from "grammy";
2
+ import { Bot, GrammyError, InlineKeyboard, InputFile } from "grammy";
3
3
  import mime from "mime";
4
4
  import telegramifyMarkdown from "telegramify-markdown";
5
5
  import log from "../logger.js";
@@ -15,6 +15,9 @@ export class TelegramChannel extends EventEmitter {
15
15
  pendingCallbacks = new Map();
16
16
  // Pending text input: chatId → resolve function (for requestText follow-ups)
17
17
  pendingTextResolvers = new Map();
18
+ // Our bot's @username, captured at connect. Used to strip the `@bot` suffix
19
+ // Telegram appends to commands in group chats.
20
+ botUsername;
18
21
  constructor(botToken, isAuthorized, onUnauthorizedDM) {
19
22
  super();
20
23
  this.botToken = botToken;
@@ -22,6 +25,31 @@ export class TelegramChannel extends EventEmitter {
22
25
  this.onUnauthorizedDM = onUnauthorizedDM;
23
26
  this.bot = new Bot(botToken);
24
27
  }
28
+ /**
29
+ * Strip the `@<botUsername>` suffix Telegram appends to commands in group
30
+ * chats (`/context@mybot args` → `/context args`), so downstream command
31
+ * handling — ClearClaw's own `/new` etc. and slash-command pass-through —
32
+ * sees the bare `/command`. No-op for non-command text and in DMs (no suffix).
33
+ * Telegram usernames are `[A-Za-z0-9_]`, so they need no regex escaping.
34
+ */
35
+ stripCommandMention(text) {
36
+ if (!this.botUsername || !text.startsWith("/"))
37
+ return text;
38
+ return text.replace(new RegExp(`^(/[^\\s@]+)@${this.botUsername}(?=\\s|$)`, "i"), "$1");
39
+ }
40
+ async telegramCall(call, label) {
41
+ try {
42
+ return await call();
43
+ }
44
+ catch (err) {
45
+ const retryAfter = telegramRetryAfterSeconds(err);
46
+ if (retryAfter === null)
47
+ throw err;
48
+ log.warn("[channel] Telegram rate limited during %s, retrying after %ss", label, retryAfter);
49
+ await sleep((retryAfter + 1) * 1000);
50
+ return await call();
51
+ }
52
+ }
25
53
  async connect() {
26
54
  this.bot.on("message:text", (ctx) => {
27
55
  const sender = this.extractSender(ctx);
@@ -36,7 +64,7 @@ export class TelegramChannel extends EventEmitter {
36
64
  }
37
65
  this.emit("message", {
38
66
  ...sender,
39
- text: ctx.message.text,
67
+ text: this.stripCommandMention(ctx.message.text),
40
68
  messageId: String(ctx.message.message_id),
41
69
  replyTo: this.extractReplyContext(ctx.message),
42
70
  });
@@ -89,6 +117,7 @@ export class TelegramChannel extends EventEmitter {
89
117
  await new Promise((resolve) => {
90
118
  this.bot.start({
91
119
  onStart: (botInfo) => {
120
+ this.botUsername = botInfo.username;
92
121
  log.info(`[channel] Telegram bot: @${botInfo.username}`);
93
122
  resolve();
94
123
  },
@@ -106,6 +135,10 @@ export class TelegramChannel extends EventEmitter {
106
135
  ownsId(chatId) {
107
136
  return chatId.startsWith("tg:");
108
137
  }
138
+ /** A private chat is the user's own id; a ":thread" suffix means a topic inside it. */
139
+ isRootDM(chatId, userId) {
140
+ return /^tg:[1-9]\d*$/.test(chatId) && chatId === userId;
141
+ }
109
142
  async sendMessage(chatId, text, opts) {
110
143
  const numId = this.numericId(chatId);
111
144
  const chunks = splitMessage(text);
@@ -117,21 +150,22 @@ export class TelegramChannel extends EventEmitter {
117
150
  ? { reply_parameters: { message_id: Number(opts.replyToMessageId) } }
118
151
  : {};
119
152
  if (plain) {
120
- const sent = await this.bot.api.sendMessage(numId, chunk, replyParams);
153
+ const sent = await this.telegramCall(() => this.bot.api.sendMessage(numId, chunk, { ...this.threadOpts(chatId), ...replyParams }), "sendMessage");
121
154
  handles.push(String(sent.message_id));
122
155
  }
123
156
  else {
124
157
  const rendered = telegramifyMarkdown(chunk, "escape");
125
158
  try {
126
- const sent = await this.bot.api.sendMessage(numId, rendered, {
159
+ const sent = await this.telegramCall(() => this.bot.api.sendMessage(numId, rendered, {
160
+ ...this.threadOpts(chatId),
127
161
  parse_mode: "MarkdownV2",
128
162
  ...replyParams,
129
- });
163
+ }), "sendMessage");
130
164
  handles.push(String(sent.message_id));
131
165
  }
132
166
  catch (err) {
133
167
  log.warn("[channel] sendMessage failed with MarkdownV2, retrying as plain text");
134
- const sent = await this.bot.api.sendMessage(numId, chunk, replyParams);
168
+ const sent = await this.telegramCall(() => this.bot.api.sendMessage(numId, chunk, { ...this.threadOpts(chatId), ...replyParams }), "sendMessage");
135
169
  handles.push(String(sent.message_id));
136
170
  }
137
171
  }
@@ -164,16 +198,18 @@ export class TelegramChannel extends EventEmitter {
164
198
  const mdv2Text = telegramifyMarkdown(text, "escape");
165
199
  let sent;
166
200
  try {
167
- sent = await this.bot.api.sendMessage(numId, mdv2Text, {
201
+ sent = await this.telegramCall(() => this.bot.api.sendMessage(numId, mdv2Text, {
202
+ ...this.threadOpts(chatId),
168
203
  reply_markup: keyboard,
169
204
  parse_mode: "MarkdownV2",
170
- });
205
+ }), "sendInteractive");
171
206
  }
172
207
  catch {
173
208
  log.warn("[channel] sendInteractive failed with MarkdownV2, retrying as plain text");
174
- sent = await this.bot.api.sendMessage(numId, text, {
209
+ sent = await this.telegramCall(() => this.bot.api.sendMessage(numId, text, {
210
+ ...this.threadOpts(chatId),
175
211
  reply_markup: keyboard,
176
- });
212
+ }), "sendInteractive");
177
213
  }
178
214
  const cleanupAll = () => {
179
215
  for (const { cbData } of cbEntries) {
@@ -201,17 +237,18 @@ export class TelegramChannel extends EventEmitter {
201
237
  feedbackKeyboard.row();
202
238
  }
203
239
  try {
204
- await this.bot.api.editMessageReplyMarkup(numId, sent.message_id, {
240
+ await this.telegramCall(() => this.bot.api.editMessageReplyMarkup(numId, sent.message_id, {
205
241
  reply_markup: feedbackKeyboard,
206
- });
242
+ }), "editMessageReplyMarkup");
207
243
  }
208
244
  catch { /* best-effort */ }
209
245
  // If the button requests text, prompt for follow-up input
210
246
  if (pressed.requestText) {
211
- await this.bot.api.sendMessage(numId, "Add your feedback:", {
247
+ await this.telegramCall(() => this.bot.api.sendMessage(numId, "Add your feedback:", {
248
+ ...this.threadOpts(chatId),
212
249
  reply_markup: { force_reply: true },
213
250
  reply_parameters: { message_id: sent.message_id },
214
- });
251
+ }), "sendInteractiveFollowUp");
215
252
  const followUpText = await new Promise((resolve) => {
216
253
  this.pendingTextResolvers.set(chatId, resolve);
217
254
  });
@@ -222,15 +259,15 @@ export class TelegramChannel extends EventEmitter {
222
259
  async editMessage(chatId, handle, text, opts) {
223
260
  const numId = this.numericId(chatId);
224
261
  if (opts?.format === "plain") {
225
- await this.bot.api.editMessageText(numId, Number(handle), text);
262
+ await this.telegramCall(() => this.bot.api.editMessageText(numId, Number(handle), text), "editMessage");
226
263
  }
227
264
  else {
228
265
  const rendered = telegramifyMarkdown(text, "escape");
229
266
  try {
230
- await this.bot.api.editMessageText(numId, Number(handle), rendered, { parse_mode: "MarkdownV2" });
267
+ await this.telegramCall(() => this.bot.api.editMessageText(numId, Number(handle), rendered, { parse_mode: "MarkdownV2" }), "editMessage");
231
268
  }
232
269
  catch {
233
- await this.bot.api.editMessageText(numId, Number(handle), text);
270
+ await this.telegramCall(() => this.bot.api.editMessageText(numId, Number(handle), text), "editMessage");
234
271
  }
235
272
  }
236
273
  }
@@ -240,13 +277,18 @@ export class TelegramChannel extends EventEmitter {
240
277
  }
241
278
  async pinMessage(chatId, handle) {
242
279
  const numId = this.numericId(chatId);
243
- await this.bot.api.pinChatMessage(numId, Number(handle), {
280
+ await this.telegramCall(() => this.bot.api.pinChatMessage(numId, Number(handle), {
244
281
  disable_notification: true,
245
- });
282
+ }), "pinMessage");
246
283
  }
284
+ /** Unpin everything in this chat's scope: the forum topic for a topic chat, else the whole chat. */
247
285
  async unpinAllMessages(chatId) {
248
286
  const numId = this.numericId(chatId);
249
- await this.bot.api.unpinAllChatMessages(numId);
287
+ const { message_thread_id } = this.threadOpts(chatId);
288
+ if (message_thread_id)
289
+ await this.bot.api.unpinAllForumTopicMessages(numId, message_thread_id);
290
+ else
291
+ await this.bot.api.unpinAllChatMessages(numId);
250
292
  }
251
293
  async updateStatus(chatId, text) {
252
294
  const existing = this.statusHandles.get(chatId);
@@ -266,7 +308,9 @@ export class TelegramChannel extends EventEmitter {
266
308
  }
267
309
  }
268
310
  }
269
- // No existing handle, or edit failed — create new pinned message
311
+ // No existing handle, or edit failed — (re)create the pinned status. The handle lives only in
312
+ // memory, so after a restart the previous status is unknown; clear every pin in scope instead
313
+ // (topic-scoped for forum topics, so peers in other topics keep theirs).
270
314
  try {
271
315
  await this.unpinAllMessages(chatId);
272
316
  }
@@ -288,10 +332,10 @@ export class TelegramChannel extends EventEmitter {
288
332
  const numId = this.numericId(chatId);
289
333
  // Send immediately, then repeat every 4s
290
334
  await this.bot.api
291
- .sendChatAction(numId, "typing")
335
+ .sendChatAction(numId, "typing", this.threadOpts(chatId))
292
336
  .catch(() => { });
293
337
  const interval = setInterval(() => {
294
- this.bot.api.sendChatAction(numId, "typing").catch(() => { });
338
+ this.bot.api.sendChatAction(numId, "typing", this.threadOpts(chatId)).catch(() => { });
295
339
  }, 4000);
296
340
  this.typingIntervals.set(chatId, interval);
297
341
  }
@@ -323,10 +367,13 @@ export class TelegramChannel extends EventEmitter {
323
367
  }
324
368
  return null;
325
369
  }
370
+ const topicSuffix = ctx.message?.is_topic_message && ctx.message.message_thread_id
371
+ ? `:${ctx.message.message_thread_id}`
372
+ : "";
326
373
  return {
327
- chatId: `tg:${ctx.chat.id}`,
374
+ chatId: `tg:${ctx.chat.id}${topicSuffix}`,
328
375
  chatType: ctx.chat.type === "private" ? "dm" : "group",
329
- user,
376
+ origin: { kind: "user", user },
330
377
  };
331
378
  }
332
379
  /** Extract reply context from a Telegram message, if it's a reply. */
@@ -387,16 +434,16 @@ export class TelegramChannel extends EventEmitter {
387
434
  const file = new InputFile(buffer, filename);
388
435
  const caption = opts?.caption;
389
436
  if (mimeType.startsWith("image/")) {
390
- await this.bot.api.sendPhoto(id, file, { caption });
437
+ await this.bot.api.sendPhoto(id, file, { caption, ...this.threadOpts(chatId) });
391
438
  }
392
439
  else if (mimeType.startsWith("video/")) {
393
- await this.bot.api.sendVideo(id, file, { caption });
440
+ await this.bot.api.sendVideo(id, file, { caption, ...this.threadOpts(chatId) });
394
441
  }
395
442
  else if (mimeType.startsWith("audio/")) {
396
- await this.bot.api.sendAudio(id, file, { caption });
443
+ await this.bot.api.sendAudio(id, file, { caption, ...this.threadOpts(chatId) });
397
444
  }
398
445
  else {
399
- await this.bot.api.sendDocument(id, file, { caption });
446
+ await this.bot.api.sendDocument(id, file, { caption, ...this.threadOpts(chatId) });
400
447
  }
401
448
  }
402
449
  async reactToMessage(chatId, messageId, emoji) {
@@ -411,6 +458,45 @@ export class TelegramChannel extends EventEmitter {
411
458
  log.warn("[channel] failed to set reaction on %s: %s", messageId, err instanceof Error ? err.message : String(err));
412
459
  }
413
460
  }
461
+ async createProjectChat(projectName, anchor, title) {
462
+ await this.validateProjectChat(projectName, anchor);
463
+ const topic = await this.bot.api.createForumTopic(this.numericId(anchor), title);
464
+ return `tg:${this.numericId(anchor)}:${topic.message_thread_id}`;
465
+ }
466
+ async closeProjectChat(chatId) {
467
+ const thread = chatId.split(":")[2];
468
+ if (!thread)
469
+ throw new Error("Telegram cannot close a whole chat; only topics can be archived");
470
+ const parentId = this.numericId(chatId);
471
+ if (parentId > 0) {
472
+ await this.bot.api.deleteForumTopic(parentId, Number(thread));
473
+ return;
474
+ }
475
+ try {
476
+ await this.bot.api.closeForumTopic(parentId, Number(thread));
477
+ }
478
+ catch (err) {
479
+ if (err instanceof GrammyError && err.description === "Bad Request: TOPIC_NOT_MODIFIED")
480
+ return;
481
+ throw err;
482
+ }
483
+ }
484
+ async validateProjectChat(projectName, anchor) {
485
+ const parentId = this.numericId(anchor);
486
+ if (!Number.isFinite(parentId)) {
487
+ throw new Error(`Telegram project "${projectName}" must use a valid chat ID`);
488
+ }
489
+ const chat = await this.bot.api.getChat(parentId);
490
+ if (chat.type === "private") {
491
+ const botInfo = await this.bot.api.getMe();
492
+ if (botInfo.has_topics_enabled)
493
+ return;
494
+ throw new Error(`Telegram project "${projectName}" requires Threaded Mode for this bot. Enable it in BotFather, then retry.`);
495
+ }
496
+ if (chat.type === "supergroup" && chat.is_forum)
497
+ return;
498
+ throw new Error(`Telegram project "${projectName}" requires a forum supergroup. Enable Topics manually in Telegram, then retry.`);
499
+ }
414
500
  /** Download a Telegram file by file_id, returning the raw buffer. */
415
501
  async downloadFile(fileId) {
416
502
  const file = await this.bot.api.getFile(fileId);
@@ -422,8 +508,14 @@ export class TelegramChannel extends EventEmitter {
422
508
  throw new Error(`File download failed: ${resp.status}`);
423
509
  return Buffer.from(await resp.arrayBuffer());
424
510
  }
511
+ /** "tg:123" → 123; "tg:123:45" (forum topic) → 123 */
425
512
  numericId(chatId) {
426
- return Number(chatId.replace(/^tg:/, ""));
513
+ return Number(chatId.split(":")[1]);
514
+ }
515
+ /** Topic part of a composite chat id, as spreadable send options. */
516
+ threadOpts(chatId) {
517
+ const thread = chatId.split(":")[2];
518
+ return thread ? { message_thread_id: Number(thread) } : {};
427
519
  }
428
520
  }
429
521
  const MAX_MSG_LEN = 4096;
@@ -450,4 +542,18 @@ function splitMessage(text) {
450
542
  chunks.push(remaining);
451
543
  return chunks;
452
544
  }
545
+ function telegramRetryAfterSeconds(err) {
546
+ const candidate = err;
547
+ const retryAfter = candidate?.parameters?.retry_after;
548
+ if (typeof retryAfter === "number" && Number.isFinite(retryAfter))
549
+ return retryAfter;
550
+ const message = err instanceof Error ? err.message : String(err);
551
+ const match = message.match(/retry after (\d+)/i);
552
+ if (!match)
553
+ return null;
554
+ return Number(match[1]);
555
+ }
556
+ function sleep(ms) {
557
+ return new Promise((resolve) => setTimeout(resolve, ms));
558
+ }
453
559
  //# sourceMappingURL=telegram.js.map
package/dist/config.d.ts CHANGED
@@ -24,7 +24,21 @@ export interface PendingPairing {
24
24
  export interface EngineEntry {
25
25
  name: string;
26
26
  default?: boolean;
27
- path: string;
27
+ path?: string;
28
+ configPath?: string;
29
+ }
30
+ export interface ScheduleEntry {
31
+ id: string;
32
+ cron: string;
33
+ prompt: string;
34
+ enabled: boolean;
35
+ timezone?: string;
36
+ createdAt: number;
37
+ }
38
+ export interface Project {
39
+ name: string;
40
+ description: string;
41
+ main_workspace: string;
28
42
  }
29
43
  export declare function resolveDataDir(): string;
30
44
  /**
@@ -61,16 +75,38 @@ export declare class Config {
61
75
  hasUser(userId: string): boolean;
62
76
  addUser(user: AuthorizedUser): void;
63
77
  listUsers(): AuthorizedUser[];
78
+ /** All authorized IDs, including environment-configured and pairing-approved users. */
79
+ listAuthorizedUserIds(): string[];
64
80
  /** env var IDs (in-memory) + pairing-approved users (hot-reload from disk) */
65
81
  isAuthorized(userId: string): boolean;
66
82
  createPairing(userId: string, userName: string, chatId: string): string;
67
83
  consumePairing(code: string): PendingPairing | null;
68
84
  private generateCode;
85
+ /** Home exists before a chat is paired. Never replace an existing home's settings. */
86
+ ensureHomeWorkspace(): Workspace;
87
+ /**
88
+ * ensureHomeWorkspace creates home's records at startup, before any chat is known.
89
+ * This gives it its destination, once: an existing binding is never replaced.
90
+ * Callers decide what counts as home's chat — see Channel.isRootDM.
91
+ */
92
+ connectHomeWorkspace(chatId: string): boolean;
69
93
  workspaceByChat(chatId: string): Workspace | undefined;
70
94
  workspaceByName(name: string): Workspace | undefined;
95
+ listWorkspaces(): Workspace[];
71
96
  upsertWorkspace(ws: Workspace): void;
97
+ removeWorkspace(name: string): Workspace | undefined;
98
+ /** Persist the session ID without changing the saved model setting. */
72
99
  setSession(name: string, sessionId: string): void;
73
100
  clearSession(name: string): void;
101
+ setModel(name: string, model: string | undefined): void;
74
102
  setBehavior(name: string, behavior: "assistant" | "relay"): void;
103
+ addProject(project: Project): void;
104
+ listProjects(): Project[];
105
+ projectByName(name: string): Project | undefined;
106
+ removeProject(name: string): void;
107
+ listSchedules(): ScheduleEntry[];
108
+ addSchedule(entry: ScheduleEntry): void;
109
+ removeSchedule(id: string): void;
110
+ updateSchedule(id: string, update: Partial<Pick<ScheduleEntry, "enabled" | "cron" | "prompt" | "timezone">>): void;
75
111
  approveUser(userId: string, userName: string): void;
76
112
  }
package/dist/config.js CHANGED
@@ -108,6 +108,8 @@ export class Config {
108
108
  authorizedUsers: (raw.authorizedUsers ?? []),
109
109
  pendingPairings: (raw.pendingPairings ?? []),
110
110
  workspaces: (raw.workspaces ?? []),
111
+ schedules: (raw.schedules ?? []),
112
+ projects: (raw.projects ?? []),
111
113
  };
112
114
  }
113
115
  write(data) {
@@ -142,6 +144,13 @@ export class Config {
142
144
  listUsers() {
143
145
  return this.read().authorizedUsers;
144
146
  }
147
+ /** All authorized IDs, including environment-configured and pairing-approved users. */
148
+ listAuthorizedUserIds() {
149
+ return [...new Set([
150
+ ...this.envUserIds,
151
+ ...this.listUsers().map((user) => user.id),
152
+ ])];
153
+ }
145
154
  /** env var IDs (in-memory) + pairing-approved users (hot-reload from disk) */
146
155
  isAuthorized(userId) {
147
156
  return this.envUserIds.has(userId) || this.hasUser(userId);
@@ -185,12 +194,59 @@ export class Config {
185
194
  .join("");
186
195
  }
187
196
  // --- Workspaces ---
197
+ /** Home exists before a chat is paired. Never replace an existing home's settings. */
198
+ ensureHomeWorkspace() {
199
+ fs.mkdirSync(this.homeWorkspacePath, { recursive: true });
200
+ const data = this.read();
201
+ let home = data.workspaces.find((ws) => ws.name === "default");
202
+ let changed = false;
203
+ if (!home) {
204
+ home = { name: "default", cwd: this.homeWorkspacePath, chat_id: null,
205
+ current_session_id: null, behavior: "assistant", description: "Personal assistant", project: "home" };
206
+ // Avoid adopting an unrelated project with a reserved home name.
207
+ if (data.projects.some((project) => project.name === "home" && project.main_workspace !== "default")) {
208
+ throw new Error('Project "home" already has another main workspace; rename it before creating home.');
209
+ }
210
+ data.workspaces.push(home);
211
+ changed = true;
212
+ }
213
+ if (!home.project) {
214
+ const existing = data.projects.find((project) => project.main_workspace === home.name);
215
+ home.project = existing?.name ?? "home";
216
+ if (data.projects.some((project) => project.name === home.project && project.main_workspace !== home.name)) {
217
+ throw new Error(`Project "${home.project}" already has another main workspace.`);
218
+ }
219
+ changed = true;
220
+ }
221
+ if (!data.projects.some((project) => project.name === home.project)) {
222
+ data.projects.push({ name: home.project, description: home.description ?? "Personal assistant", main_workspace: home.name });
223
+ changed = true;
224
+ }
225
+ if (changed)
226
+ this.write(data);
227
+ return home;
228
+ }
229
+ /**
230
+ * ensureHomeWorkspace creates home's records at startup, before any chat is known.
231
+ * This gives it its destination, once: an existing binding is never replaced.
232
+ * Callers decide what counts as home's chat — see Channel.isRootDM.
233
+ */
234
+ connectHomeWorkspace(chatId) {
235
+ const home = this.ensureHomeWorkspace();
236
+ if (home.chat_id || this.workspaceByChat(chatId))
237
+ return false;
238
+ this.upsertWorkspace({ ...home, chat_id: chatId });
239
+ return true;
240
+ }
188
241
  workspaceByChat(chatId) {
189
242
  return this.read().workspaces.find((ws) => ws.chat_id === chatId);
190
243
  }
191
244
  workspaceByName(name) {
192
245
  return this.read().workspaces.find((ws) => ws.name === name);
193
246
  }
247
+ listWorkspaces() {
248
+ return this.read().workspaces;
249
+ }
194
250
  upsertWorkspace(ws) {
195
251
  const data = this.read();
196
252
  const idx = data.workspaces.findIndex((w) => w.name === ws.name);
@@ -200,6 +256,16 @@ export class Config {
200
256
  data.workspaces.push(ws);
201
257
  this.write(data);
202
258
  }
259
+ removeWorkspace(name) {
260
+ const data = this.read();
261
+ const idx = data.workspaces.findIndex((w) => w.name === name);
262
+ if (idx < 0)
263
+ return undefined;
264
+ const [removed] = data.workspaces.splice(idx, 1);
265
+ this.write(data);
266
+ return removed;
267
+ }
268
+ /** Persist the session ID without changing the saved model setting. */
203
269
  setSession(name, sessionId) {
204
270
  const data = this.read();
205
271
  const ws = data.workspaces.find((w) => w.name === name);
@@ -213,6 +279,15 @@ export class Config {
213
279
  const ws = data.workspaces.find((w) => w.name === name);
214
280
  if (ws) {
215
281
  ws.current_session_id = null;
282
+ delete ws.engine_handoff;
283
+ this.write(data);
284
+ }
285
+ }
286
+ setModel(name, model) {
287
+ const data = this.read();
288
+ const ws = data.workspaces.find((w) => w.name === name);
289
+ if (ws) {
290
+ ws.model = model;
216
291
  this.write(data);
217
292
  }
218
293
  }
@@ -224,6 +299,49 @@ export class Config {
224
299
  this.write(data);
225
300
  }
226
301
  }
302
+ // --- Projects ---
303
+ addProject(project) {
304
+ const data = this.read();
305
+ const idx = data.projects.findIndex((p) => p.name === project.name);
306
+ if (idx >= 0)
307
+ data.projects[idx] = project;
308
+ else
309
+ data.projects.push(project);
310
+ this.write(data);
311
+ }
312
+ listProjects() {
313
+ return this.read().projects;
314
+ }
315
+ projectByName(name) {
316
+ return this.read().projects.find((p) => p.name === name);
317
+ }
318
+ removeProject(name) {
319
+ const data = this.read();
320
+ data.projects = data.projects.filter((p) => p.name !== name);
321
+ this.write(data);
322
+ }
323
+ // --- Schedules ---
324
+ listSchedules() {
325
+ return this.read().schedules;
326
+ }
327
+ addSchedule(entry) {
328
+ const data = this.read();
329
+ data.schedules.push(entry);
330
+ this.write(data);
331
+ }
332
+ removeSchedule(id) {
333
+ const data = this.read();
334
+ data.schedules = data.schedules.filter((s) => s.id !== id);
335
+ this.write(data);
336
+ }
337
+ updateSchedule(id, update) {
338
+ const data = this.read();
339
+ const entry = data.schedules.find((s) => s.id === id);
340
+ if (entry) {
341
+ Object.assign(entry, update);
342
+ this.write(data);
343
+ }
344
+ }
227
345
  // --- Approval ---
228
346
  approveUser(userId, userName) {
229
347
  this.addUser({ id: userId, name: userName, approvedAt: Date.now() });
@@ -1,9 +1,14 @@
1
- import type { SpawnConfig } from "./registry.js";
2
- import type { Engine, EngineEvent, RunTurnOpts, SessionInfo } from "../types.js";
1
+ import type { AcpEngineDefinition } from "./registry.js";
2
+ import type { Engine, EngineEvent, PlanUsageWindow, RunTurnOpts, SessionInfo, SessionHistoryOpts, SessionMessage } from "../types.js";
3
3
  export declare class AcpEngine implements Engine {
4
4
  readonly name: string;
5
5
  private readonly spawnConfig;
6
- constructor(name: string, spawnConfig: SpawnConfig);
6
+ constructor(name: string, spawnConfig: AcpEngineDefinition);
7
7
  runTurn(opts: RunTurnOpts): AsyncIterable<EngineEvent>;
8
- listSessions(_cwd: string): Promise<SessionInfo[]>;
8
+ getSessionMessages(opts: SessionHistoryOpts): Promise<SessionMessage[]>;
9
+ listSessions(cwd: string): Promise<SessionInfo[]>;
10
+ /** Short-lived, read-only ACP queries share one deadline and always release the child. */
11
+ private readSessionData;
9
12
  }
13
+ /** PR #334's provider extension. Never treat standard ACP used/size as plan usage. */
14
+ export declare function codexPlanUsage(meta: unknown): PlanUsageWindow[];