clearclaw 0.7.0 → 0.9.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
  });
@@ -54,10 +82,17 @@ export class TelegramChannel extends EventEmitter {
54
82
  const doc = ctx.message.document;
55
83
  await this.handleFileMessage(ctx, doc.file_id, doc.mime_type ?? "application/octet-stream", doc.file_name, ctx.message.caption);
56
84
  });
85
+ // Voice notes and audio files: the orchestrator transcribes them
86
+ this.bot.on("message:voice", async (ctx) => {
87
+ const voice = ctx.message.voice;
88
+ await this.handleFileMessage(ctx, voice.file_id, voice.mime_type ?? "audio/ogg", "voice.ogg", ctx.message.caption);
89
+ });
90
+ this.bot.on("message:audio", async (ctx) => {
91
+ const audio = ctx.message.audio;
92
+ await this.handleFileMessage(ctx, audio.file_id, audio.mime_type ?? "audio/mpeg", audio.file_name, ctx.message.caption);
93
+ });
57
94
  // Placeholder handlers for unsupported media types
58
95
  for (const [filter, label] of [
59
- ["message:voice", "Voice message"],
60
- ["message:audio", "Audio message"],
61
96
  ["message:video", "Video"],
62
97
  ["message:sticker", "Sticker"],
63
98
  ]) {
@@ -89,6 +124,7 @@ export class TelegramChannel extends EventEmitter {
89
124
  await new Promise((resolve) => {
90
125
  this.bot.start({
91
126
  onStart: (botInfo) => {
127
+ this.botUsername = botInfo.username;
92
128
  log.info(`[channel] Telegram bot: @${botInfo.username}`);
93
129
  resolve();
94
130
  },
@@ -106,6 +142,10 @@ export class TelegramChannel extends EventEmitter {
106
142
  ownsId(chatId) {
107
143
  return chatId.startsWith("tg:");
108
144
  }
145
+ /** A private chat is the user's own id; a ":thread" suffix means a topic inside it. */
146
+ isRootDM(chatId, userId) {
147
+ return /^tg:[1-9]\d*$/.test(chatId) && chatId === userId;
148
+ }
109
149
  async sendMessage(chatId, text, opts) {
110
150
  const numId = this.numericId(chatId);
111
151
  const chunks = splitMessage(text);
@@ -117,21 +157,22 @@ export class TelegramChannel extends EventEmitter {
117
157
  ? { reply_parameters: { message_id: Number(opts.replyToMessageId) } }
118
158
  : {};
119
159
  if (plain) {
120
- const sent = await this.bot.api.sendMessage(numId, chunk, replyParams);
160
+ const sent = await this.telegramCall(() => this.bot.api.sendMessage(numId, chunk, { ...this.threadOpts(chatId), ...replyParams }), "sendMessage");
121
161
  handles.push(String(sent.message_id));
122
162
  }
123
163
  else {
124
164
  const rendered = telegramifyMarkdown(chunk, "escape");
125
165
  try {
126
- const sent = await this.bot.api.sendMessage(numId, rendered, {
166
+ const sent = await this.telegramCall(() => this.bot.api.sendMessage(numId, rendered, {
167
+ ...this.threadOpts(chatId),
127
168
  parse_mode: "MarkdownV2",
128
169
  ...replyParams,
129
- });
170
+ }), "sendMessage");
130
171
  handles.push(String(sent.message_id));
131
172
  }
132
173
  catch (err) {
133
174
  log.warn("[channel] sendMessage failed with MarkdownV2, retrying as plain text");
134
- const sent = await this.bot.api.sendMessage(numId, chunk, replyParams);
175
+ const sent = await this.telegramCall(() => this.bot.api.sendMessage(numId, chunk, { ...this.threadOpts(chatId), ...replyParams }), "sendMessage");
135
176
  handles.push(String(sent.message_id));
136
177
  }
137
178
  }
@@ -164,16 +205,18 @@ export class TelegramChannel extends EventEmitter {
164
205
  const mdv2Text = telegramifyMarkdown(text, "escape");
165
206
  let sent;
166
207
  try {
167
- sent = await this.bot.api.sendMessage(numId, mdv2Text, {
208
+ sent = await this.telegramCall(() => this.bot.api.sendMessage(numId, mdv2Text, {
209
+ ...this.threadOpts(chatId),
168
210
  reply_markup: keyboard,
169
211
  parse_mode: "MarkdownV2",
170
- });
212
+ }), "sendInteractive");
171
213
  }
172
214
  catch {
173
215
  log.warn("[channel] sendInteractive failed with MarkdownV2, retrying as plain text");
174
- sent = await this.bot.api.sendMessage(numId, text, {
216
+ sent = await this.telegramCall(() => this.bot.api.sendMessage(numId, text, {
217
+ ...this.threadOpts(chatId),
175
218
  reply_markup: keyboard,
176
- });
219
+ }), "sendInteractive");
177
220
  }
178
221
  const cleanupAll = () => {
179
222
  for (const { cbData } of cbEntries) {
@@ -201,17 +244,18 @@ export class TelegramChannel extends EventEmitter {
201
244
  feedbackKeyboard.row();
202
245
  }
203
246
  try {
204
- await this.bot.api.editMessageReplyMarkup(numId, sent.message_id, {
247
+ await this.telegramCall(() => this.bot.api.editMessageReplyMarkup(numId, sent.message_id, {
205
248
  reply_markup: feedbackKeyboard,
206
- });
249
+ }), "editMessageReplyMarkup");
207
250
  }
208
251
  catch { /* best-effort */ }
209
252
  // If the button requests text, prompt for follow-up input
210
253
  if (pressed.requestText) {
211
- await this.bot.api.sendMessage(numId, "Add your feedback:", {
254
+ await this.telegramCall(() => this.bot.api.sendMessage(numId, "Add your feedback:", {
255
+ ...this.threadOpts(chatId),
212
256
  reply_markup: { force_reply: true },
213
257
  reply_parameters: { message_id: sent.message_id },
214
- });
258
+ }), "sendInteractiveFollowUp");
215
259
  const followUpText = await new Promise((resolve) => {
216
260
  this.pendingTextResolvers.set(chatId, resolve);
217
261
  });
@@ -222,15 +266,15 @@ export class TelegramChannel extends EventEmitter {
222
266
  async editMessage(chatId, handle, text, opts) {
223
267
  const numId = this.numericId(chatId);
224
268
  if (opts?.format === "plain") {
225
- await this.bot.api.editMessageText(numId, Number(handle), text);
269
+ await this.telegramCall(() => this.bot.api.editMessageText(numId, Number(handle), text), "editMessage");
226
270
  }
227
271
  else {
228
272
  const rendered = telegramifyMarkdown(text, "escape");
229
273
  try {
230
- await this.bot.api.editMessageText(numId, Number(handle), rendered, { parse_mode: "MarkdownV2" });
274
+ await this.telegramCall(() => this.bot.api.editMessageText(numId, Number(handle), rendered, { parse_mode: "MarkdownV2" }), "editMessage");
231
275
  }
232
276
  catch {
233
- await this.bot.api.editMessageText(numId, Number(handle), text);
277
+ await this.telegramCall(() => this.bot.api.editMessageText(numId, Number(handle), text), "editMessage");
234
278
  }
235
279
  }
236
280
  }
@@ -240,13 +284,18 @@ export class TelegramChannel extends EventEmitter {
240
284
  }
241
285
  async pinMessage(chatId, handle) {
242
286
  const numId = this.numericId(chatId);
243
- await this.bot.api.pinChatMessage(numId, Number(handle), {
287
+ await this.telegramCall(() => this.bot.api.pinChatMessage(numId, Number(handle), {
244
288
  disable_notification: true,
245
- });
289
+ }), "pinMessage");
246
290
  }
291
+ /** Unpin everything in this chat's scope: the forum topic for a topic chat, else the whole chat. */
247
292
  async unpinAllMessages(chatId) {
248
293
  const numId = this.numericId(chatId);
249
- await this.bot.api.unpinAllChatMessages(numId);
294
+ const { message_thread_id } = this.threadOpts(chatId);
295
+ if (message_thread_id)
296
+ await this.bot.api.unpinAllForumTopicMessages(numId, message_thread_id);
297
+ else
298
+ await this.bot.api.unpinAllChatMessages(numId);
250
299
  }
251
300
  async updateStatus(chatId, text) {
252
301
  const existing = this.statusHandles.get(chatId);
@@ -266,7 +315,9 @@ export class TelegramChannel extends EventEmitter {
266
315
  }
267
316
  }
268
317
  }
269
- // No existing handle, or edit failed — create new pinned message
318
+ // No existing handle, or edit failed — (re)create the pinned status. The handle lives only in
319
+ // memory, so after a restart the previous status is unknown; clear every pin in scope instead
320
+ // (topic-scoped for forum topics, so peers in other topics keep theirs).
270
321
  try {
271
322
  await this.unpinAllMessages(chatId);
272
323
  }
@@ -288,10 +339,10 @@ export class TelegramChannel extends EventEmitter {
288
339
  const numId = this.numericId(chatId);
289
340
  // Send immediately, then repeat every 4s
290
341
  await this.bot.api
291
- .sendChatAction(numId, "typing")
342
+ .sendChatAction(numId, "typing", this.threadOpts(chatId))
292
343
  .catch(() => { });
293
344
  const interval = setInterval(() => {
294
- this.bot.api.sendChatAction(numId, "typing").catch(() => { });
345
+ this.bot.api.sendChatAction(numId, "typing", this.threadOpts(chatId)).catch(() => { });
295
346
  }, 4000);
296
347
  this.typingIntervals.set(chatId, interval);
297
348
  }
@@ -323,10 +374,13 @@ export class TelegramChannel extends EventEmitter {
323
374
  }
324
375
  return null;
325
376
  }
377
+ const topicSuffix = ctx.message?.is_topic_message && ctx.message.message_thread_id
378
+ ? `:${ctx.message.message_thread_id}`
379
+ : "";
326
380
  return {
327
- chatId: `tg:${ctx.chat.id}`,
381
+ chatId: `tg:${ctx.chat.id}${topicSuffix}`,
328
382
  chatType: ctx.chat.type === "private" ? "dm" : "group",
329
- user,
383
+ origin: { kind: "user", user },
330
384
  };
331
385
  }
332
386
  /** Extract reply context from a Telegram message, if it's a reply. */
@@ -352,7 +406,7 @@ export class TelegramChannel extends EventEmitter {
352
406
  mediaType,
353
407
  };
354
408
  }
355
- /** Shared handler for photo/document messages: download, emit attachment or fallback. */
409
+ /** Shared handler for file messages: download, emit attachment or fallback. */
356
410
  async handleFileMessage(ctx, fileId, mimeType, filename, caption) {
357
411
  const sender = this.extractSender(ctx);
358
412
  if (!sender)
@@ -387,16 +441,16 @@ export class TelegramChannel extends EventEmitter {
387
441
  const file = new InputFile(buffer, filename);
388
442
  const caption = opts?.caption;
389
443
  if (mimeType.startsWith("image/")) {
390
- await this.bot.api.sendPhoto(id, file, { caption });
444
+ await this.bot.api.sendPhoto(id, file, { caption, ...this.threadOpts(chatId) });
391
445
  }
392
446
  else if (mimeType.startsWith("video/")) {
393
- await this.bot.api.sendVideo(id, file, { caption });
447
+ await this.bot.api.sendVideo(id, file, { caption, ...this.threadOpts(chatId) });
394
448
  }
395
449
  else if (mimeType.startsWith("audio/")) {
396
- await this.bot.api.sendAudio(id, file, { caption });
450
+ await this.bot.api.sendAudio(id, file, { caption, ...this.threadOpts(chatId) });
397
451
  }
398
452
  else {
399
- await this.bot.api.sendDocument(id, file, { caption });
453
+ await this.bot.api.sendDocument(id, file, { caption, ...this.threadOpts(chatId) });
400
454
  }
401
455
  }
402
456
  async reactToMessage(chatId, messageId, emoji) {
@@ -411,6 +465,45 @@ export class TelegramChannel extends EventEmitter {
411
465
  log.warn("[channel] failed to set reaction on %s: %s", messageId, err instanceof Error ? err.message : String(err));
412
466
  }
413
467
  }
468
+ async createProjectChat(projectName, anchor, title) {
469
+ await this.validateProjectChat(projectName, anchor);
470
+ const topic = await this.bot.api.createForumTopic(this.numericId(anchor), title);
471
+ return `tg:${this.numericId(anchor)}:${topic.message_thread_id}`;
472
+ }
473
+ async closeProjectChat(chatId) {
474
+ const thread = chatId.split(":")[2];
475
+ if (!thread)
476
+ throw new Error("Telegram cannot close a whole chat; only topics can be archived");
477
+ const parentId = this.numericId(chatId);
478
+ if (parentId > 0) {
479
+ await this.bot.api.deleteForumTopic(parentId, Number(thread));
480
+ return;
481
+ }
482
+ try {
483
+ await this.bot.api.closeForumTopic(parentId, Number(thread));
484
+ }
485
+ catch (err) {
486
+ if (err instanceof GrammyError && err.description === "Bad Request: TOPIC_NOT_MODIFIED")
487
+ return;
488
+ throw err;
489
+ }
490
+ }
491
+ async validateProjectChat(projectName, anchor) {
492
+ const parentId = this.numericId(anchor);
493
+ if (!Number.isFinite(parentId)) {
494
+ throw new Error(`Telegram project "${projectName}" must use a valid chat ID`);
495
+ }
496
+ const chat = await this.bot.api.getChat(parentId);
497
+ if (chat.type === "private") {
498
+ const botInfo = await this.bot.api.getMe();
499
+ if (botInfo.has_topics_enabled)
500
+ return;
501
+ throw new Error(`Telegram project "${projectName}" requires Threaded Mode for this bot. Enable it in BotFather, then retry.`);
502
+ }
503
+ if (chat.type === "supergroup" && chat.is_forum)
504
+ return;
505
+ throw new Error(`Telegram project "${projectName}" requires a forum supergroup. Enable Topics manually in Telegram, then retry.`);
506
+ }
414
507
  /** Download a Telegram file by file_id, returning the raw buffer. */
415
508
  async downloadFile(fileId) {
416
509
  const file = await this.bot.api.getFile(fileId);
@@ -422,8 +515,14 @@ export class TelegramChannel extends EventEmitter {
422
515
  throw new Error(`File download failed: ${resp.status}`);
423
516
  return Buffer.from(await resp.arrayBuffer());
424
517
  }
518
+ /** "tg:123" → 123; "tg:123:45" (forum topic) → 123 */
425
519
  numericId(chatId) {
426
- return Number(chatId.replace(/^tg:/, ""));
520
+ return Number(chatId.split(":")[1]);
521
+ }
522
+ /** Topic part of a composite chat id, as spreadable send options. */
523
+ threadOpts(chatId) {
524
+ const thread = chatId.split(":")[2];
525
+ return thread ? { message_thread_id: Number(thread) } : {};
427
526
  }
428
527
  }
429
528
  const MAX_MSG_LEN = 4096;
@@ -450,4 +549,18 @@ function splitMessage(text) {
450
549
  chunks.push(remaining);
451
550
  return chunks;
452
551
  }
552
+ function telegramRetryAfterSeconds(err) {
553
+ const candidate = err;
554
+ const retryAfter = candidate?.parameters?.retry_after;
555
+ if (typeof retryAfter === "number" && Number.isFinite(retryAfter))
556
+ return retryAfter;
557
+ const message = err instanceof Error ? err.message : String(err);
558
+ const match = message.match(/retry after (\d+)/i);
559
+ if (!match)
560
+ return null;
561
+ return Number(match[1]);
562
+ }
563
+ function sleep(ms) {
564
+ return new Promise((resolve) => setTimeout(resolve, ms));
565
+ }
453
566
  //# 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[];