@gitterm/bot 0.1.1 → 0.2.1

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/dist/bot.d.ts CHANGED
@@ -3,4 +3,4 @@ import type { Bot, BotOptions } from "./types.js";
3
3
  * A chat bot backed by GitTerm sandboxes: one sandbox per repository that pauses when idle and
4
4
  * wakes on the next message, and one OpenCode session per chat thread.
5
5
  */
6
- export declare function createBot(options: BotOptions): Bot;
6
+ export declare function createBot(initial: BotOptions): Bot;
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { resolve as resolvePath } from "node:path";
4
4
  import {
5
5
  AgentRunError,
6
- createGittermClient,
6
+ createGittermClient as createGittermClient2,
7
7
  GittermError as GittermError2,
8
8
  WorkspaceLifecycleError
9
9
  } from "@gitterm/sdk";
@@ -94,8 +94,17 @@ function agentInstructions(platform, extra) {
94
94
  `- Use the question tool only when a decision genuinely blocks you; the person answers with buttons in ${platform}. Do not ask for confirmation of routine steps.`,
95
95
  `- You cannot send files to ${platform}; describe results or link to them instead.`
96
96
  ];
97
- return [lines.join(`
98
- `), extra?.trim()].filter(Boolean).join(`
97
+ const owner = extra?.trim();
98
+ if (!owner)
99
+ return lines.join(`
100
+ `);
101
+ return [
102
+ lines.join(`
103
+ `),
104
+ "## Instructions from this bot's owner",
105
+ "These take precedence over the defaults above.",
106
+ owner
107
+ ].join(`
99
108
 
100
109
  `);
101
110
  }
@@ -306,6 +315,260 @@ ${SETUP_HINT}`);
306
315
  }
307
316
  }
308
317
 
318
+ // src/saved.ts
319
+ import {
320
+ createGittermClient
321
+ } from "@gitterm/sdk";
322
+
323
+ // src/env.ts
324
+ import { existsSync, readFileSync } from "node:fs";
325
+ import { parseArgs, parseEnv } from "node:util";
326
+ var read = (env, name) => env[name]?.trim() || undefined;
327
+ function positive(env, name) {
328
+ const value = read(env, name);
329
+ if (!value)
330
+ return;
331
+ const parsed = Number(value);
332
+ if (!Number.isInteger(parsed) || parsed <= 0)
333
+ throw new Error(`${name} must be a positive integer`);
334
+ return parsed;
335
+ }
336
+ function parseChannels(value) {
337
+ const channels = {};
338
+ for (const entry of value.split(",")) {
339
+ const [channel, repo] = entry.split("=", 2).map((part) => part.trim());
340
+ if (!channel || !repo) {
341
+ throw new Error(`GITTERM_BOT_CHANNELS entries look like <channel id>=<repository url>`);
342
+ }
343
+ channels[channel] = repo;
344
+ }
345
+ return channels;
346
+ }
347
+ var list = (value) => value.split(",").map((entry) => entry.trim()).filter(Boolean);
348
+ function readSandboxEnv(path) {
349
+ if (!path)
350
+ return;
351
+ try {
352
+ const parsed = parseEnv(readFileSync(path, "utf8"));
353
+ return Object.fromEntries(Object.entries(parsed).filter((entry) => entry[1] !== undefined));
354
+ } catch (error) {
355
+ throw new Error(`Could not read the sandbox environment file ${path}`, { cause: error });
356
+ }
357
+ }
358
+ function readInstructionsFile(path) {
359
+ if (!path)
360
+ return;
361
+ try {
362
+ return readFileSync(path, "utf8").trim() || undefined;
363
+ } catch (error) {
364
+ throw new Error(`Could not read the instructions file ${path}`, { cause: error });
365
+ }
366
+ }
367
+ function botOptionsFromEnv(env = process.env) {
368
+ const connections = read(env, "GITTERM_BOT_CONNECTIONS");
369
+ const provider = read(env, "GITTERM_BOT_PROVIDER");
370
+ const channels = read(env, "GITTERM_BOT_CHANNELS");
371
+ const options = {};
372
+ const botId = read(env, "GITTERM_BOT_ID");
373
+ if (botId)
374
+ options.botId = botId;
375
+ const allowedChannels = read(env, "GITTERM_BOT_ALLOWED_CHANNELS");
376
+ if (allowedChannels)
377
+ options.allowedChannels = list(allowedChannels);
378
+ const approvers = read(env, "GITTERM_BOT_APPROVERS");
379
+ if (approvers)
380
+ options.approvers = list(approvers);
381
+ if (read(env, "GITTERM_BOT_ALLOW_ALWAYS") === "true")
382
+ options.allowAlways = true;
383
+ if (read(env, "GITTERM_BOT_SHARE_CHANNELS") === "true")
384
+ options.shareChannels = true;
385
+ const maxPendingRequests = positive(env, "GITTERM_BOT_MAX_PENDING");
386
+ if (maxPendingRequests)
387
+ options.maxPendingRequests = maxPendingRequests;
388
+ const maxPendingPerUser = positive(env, "GITTERM_BOT_MAX_PENDING_PER_USER");
389
+ if (maxPendingPerUser)
390
+ options.maxPendingPerUser = maxPendingPerUser;
391
+ const repo = read(env, "GITTERM_BOT_REPO");
392
+ if (repo)
393
+ options.repo = repo;
394
+ if (channels)
395
+ options.channels = parseChannels(channels);
396
+ if (connections)
397
+ options.connections = list(connections);
398
+ const repos = read(env, "GITTERM_BOT_REPOS");
399
+ if (repos)
400
+ options.repos = list(repos);
401
+ const allowedUsers = read(env, "GITTERM_BOT_ALLOWED_USERS");
402
+ if (allowedUsers)
403
+ options.allowedUsers = list(allowedUsers);
404
+ if (read(env, "GITTERM_BOT_ALLOW_GUESTS") === "true")
405
+ options.allowGuests = true;
406
+ const setup = read(env, "GITTERM_BOT_SETUP");
407
+ if (setup)
408
+ options.setup = [setup];
409
+ const sandboxEnv = readSandboxEnv(read(env, "GITTERM_BOT_SANDBOX_ENV_FILE"));
410
+ if (sandboxEnv)
411
+ options.env = sandboxEnv;
412
+ const model = read(env, "GITTERM_BOT_MODEL");
413
+ const credential = read(env, "GITTERM_BOT_MODEL_CREDENTIAL");
414
+ const apiKey = read(env, "GITTERM_BOT_MODEL_API_KEY");
415
+ if ((credential || apiKey) && !model) {
416
+ throw new Error("GITTERM_BOT_MODEL_CREDENTIAL and GITTERM_BOT_MODEL_API_KEY need GITTERM_BOT_MODEL");
417
+ }
418
+ if (model) {
419
+ options.model = {
420
+ id: model,
421
+ ...credential ? { credential } : {},
422
+ ...apiKey ? { apiKey } : {}
423
+ };
424
+ }
425
+ const instructions = [
426
+ read(env, "GITTERM_BOT_INSTRUCTIONS"),
427
+ readInstructionsFile(read(env, "GITTERM_BOT_INSTRUCTIONS_FILE"))
428
+ ].filter(Boolean).join(`
429
+
430
+ `);
431
+ if (instructions)
432
+ options.instructions = instructions;
433
+ const githubToken = read(env, "GITTERM_BOT_GITHUB_TOKEN");
434
+ if (provider || githubToken) {
435
+ options.workspace = {
436
+ ...provider ? { provider: { type: provider } } : {},
437
+ ...githubToken ? { repositoryCredentials: { token: githubToken } } : {}
438
+ };
439
+ }
440
+ const stateFile = read(env, "GITTERM_BOT_STATE_FILE");
441
+ if (stateFile)
442
+ options.stateFile = stateFile;
443
+ const runTimeoutMinutes = positive(env, "GITTERM_BOT_RUN_TIMEOUT_MINUTES");
444
+ if (runTimeoutMinutes)
445
+ options.runTimeoutMs = runTimeoutMinutes * 60000;
446
+ return options;
447
+ }
448
+ function cliOptions(argv = process.argv.slice(2)) {
449
+ if (existsSync(".env") && typeof process.loadEnvFile === "function")
450
+ process.loadEnvFile(".env");
451
+ const { values, positionals } = parseArgs({
452
+ args: argv,
453
+ allowPositionals: true,
454
+ options: {
455
+ repo: { type: "string" },
456
+ model: { type: "string" },
457
+ "instructions-file": { type: "string" },
458
+ help: { type: "boolean", short: "h" }
459
+ }
460
+ });
461
+ const flags = {
462
+ GITTERM_BOT_REPO: values.repo,
463
+ GITTERM_BOT_MODEL: values.model,
464
+ GITTERM_BOT_INSTRUCTIONS_FILE: values["instructions-file"]
465
+ };
466
+ const env = { ...process.env };
467
+ for (const [name, value] of Object.entries(flags))
468
+ if (value)
469
+ env[name] = value;
470
+ return { command: values.help ? "help" : positionals[0], options: botOptionsFromEnv(env) };
471
+ }
472
+ var BOT_ENV_HELP = ` Saved policy loads at startup; local settings cannot broaden its users, guests, or channels.
473
+
474
+ GITTERM_API_TOKEN The bot's GitTerm token (dashboard → Bots creates one)
475
+ GITTERM_SERVER_URL Self-hosted GitTerm API URL (default: hosted)
476
+ GITTERM_BOT_REPO Repository for every channel: https://github.com/acme/app[#branch]
477
+ GITTERM_BOT_REPOS More repositories people can name in a thread: url,url
478
+ GITTERM_BOT_CHANNELS Per-channel repositories: <channel id>=<url>,…
479
+ GITTERM_BOT_MODEL provider/model, e.g. anthropic/claude-sonnet-5-5
480
+ GITTERM_BOT_MODEL_CREDENTIAL Saved credential label (default: the provider's default)
481
+ GITTERM_BOT_MODEL_API_KEY Or a model API key for this bot only
482
+ GITTERM_BOT_GITHUB_TOKEN Your own GitHub token instead of a GitTerm GitHub connection
483
+ GITTERM_BOT_CONNECTIONS Tools besides GitHub, by name: Linear,Sentry
484
+ GITTERM_BOT_ALLOWED_USERS Only these user ids may use the bot: U012,U034
485
+ GITTERM_BOT_ID Names an unsaved bot's sandboxes (saved bots use their own id)
486
+ GITTERM_BOT_ALLOWED_CHANNELS Only these channel ids, whatever the repository routing
487
+ GITTERM_BOT_APPROVERS Permission approver ids (default: requester only)
488
+ GITTERM_BOT_ALLOW_ALWAYS true enables persistent approvals (default: disabled)
489
+ GITTERM_BOT_SHARE_CHANNELS true opts equally trusted channels into a shared sandbox
490
+ GITTERM_BOT_MAX_PENDING[_PER_USER] Queue bounds (defaults: 20 total, 3 per user)
491
+ GITTERM_BOT_ALLOW_GUESTS true lets guests and external people use the bot
492
+ GITTERM_BOT_INSTRUCTIONS[_FILE] What the agent should know and how to behave
493
+ GITTERM_BOT_SETUP Command run before the agent starts, e.g. "pnpm install"
494
+ GITTERM_BOT_SANDBOX_ENV_FILE dotenv file of variables for the sandbox (test credentials)
495
+ GITTERM_BOT_PROVIDER Compute provider for new sandboxes (railway, e2b, …)
496
+ GITTERM_BOT_STATE_FILE, GITTERM_BOT_RUN_TIMEOUT_MINUTES`;
497
+ var hasRepository = (options) => Boolean(options.repo || options.repos?.length || Object.keys(options.channels ?? {}).length);
498
+
499
+ // src/saved.ts
500
+ var isClient = (value) => typeof value === "object" && value !== null && ("runs" in value);
501
+ async function withSavedConfig(options, platform, log = console) {
502
+ const gitterm = isClient(options.gitterm) ? options.gitterm : createGittermClient(options.gitterm);
503
+ const base = { ...options, gitterm };
504
+ const saved = await gitterm.bots.self();
505
+ if (!saved)
506
+ return base;
507
+ if (saved.platform !== platform) {
508
+ throw new Error(`This GitTerm token belongs to the ${saved.platform} bot "${saved.name}"; run that bot with it`);
509
+ }
510
+ log.info(`Using the settings saved for "${saved.name}" in GitTerm.`);
511
+ return { ...applySavedConfig(base, saved, log), localOptions: base };
512
+ }
513
+ function applySavedConfig(options, saved, log = console) {
514
+ const merged = { ...options };
515
+ const localRepos = [
516
+ options.repo,
517
+ ...options.repos ?? [],
518
+ ...Object.values(options.channels ?? {})
519
+ ].filter((repo) => repo !== undefined);
520
+ if (localRepos.some((repo) => repoKey(parseRepo(repo)) !== repoKey(parseRepo(saved.repo)))) {
521
+ throw new Error("Local repository routing exceeds the saved bot policy. Change its repository in GitTerm first.");
522
+ }
523
+ const localModel = typeof options.model === "string" ? options.model : options.model?.id;
524
+ if (localModel && localModel !== saved.model)
525
+ throw new Error("Local model exceeds the saved bot policy. Change its model in GitTerm first.");
526
+ if (options.connections?.some((reference) => !saved.connections.includes(reference))) {
527
+ throw new Error("Local connections exceed the saved bot policy. Select them in GitTerm first.");
528
+ }
529
+ merged.botId = saved.id;
530
+ if (saved.channels.length) {
531
+ merged.allowedChannels = options.allowedChannels ? options.allowedChannels.filter((id) => saved.channels.includes(id)) : saved.channels;
532
+ }
533
+ if (!hasRepository(options)) {
534
+ if (saved.channels.length) {
535
+ merged.channels = Object.fromEntries(saved.channels.map((id) => [id, saved.repo]));
536
+ } else {
537
+ merged.repo = saved.repo;
538
+ }
539
+ }
540
+ merged.model ??= {
541
+ id: saved.model,
542
+ ...saved.credential ? { credential: saved.credential } : {}
543
+ };
544
+ merged.connections ??= saved.connections;
545
+ if (saved.provider && !options.workspace?.provider) {
546
+ merged.workspace = {
547
+ ...options.workspace,
548
+ provider: { type: saved.provider }
549
+ };
550
+ }
551
+ if (saved.allowedUsers.length) {
552
+ merged.allowedUsers = options.allowedUsers ? options.allowedUsers.filter((id) => saved.allowedUsers.includes(id)) : saved.allowedUsers;
553
+ }
554
+ merged.allowGuests = saved.allowGuests && (options.allowGuests ?? true);
555
+ if (saved.instructions)
556
+ merged.instructions ??= saved.instructions;
557
+ if (saved.setup)
558
+ merged.setup ??= [saved.setup];
559
+ if (saved.githubAccess === "token" && !merged.workspace?.repositoryCredentials) {
560
+ log.warn(`"${saved.name}" uses your own GitHub token, but GITTERM_BOT_GITHUB_TOKEN is not set. Public repositories still work.`);
561
+ }
562
+ log.info("Effective bot authorization", {
563
+ botId: saved.id,
564
+ source: "saved policy, intersected with local restrictions",
565
+ channels: merged.allowedChannels ?? "all invited channels",
566
+ users: merged.allowedUsers ?? "channel members",
567
+ allowGuests: merged.allowGuests
568
+ });
569
+ return merged;
570
+ }
571
+
309
572
  // src/state.ts
310
573
  import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
311
574
  import { dirname } from "node:path";
@@ -393,12 +656,14 @@ async function openStatusLine(adapter, thread, messageId) {
393
656
  const ms = Date.now() - startedAt;
394
657
  return ms >= TICK_MS ? ` (${minutes(ms)} min)` : "";
395
658
  };
396
- const render = () => {
659
+ let queue = Promise.resolve();
660
+ const send = (update) => queue = queue.then(update).catch(noop);
661
+ const render = () => send(() => {
397
662
  if (id)
398
- return adapter.edit(thread, id, `${current.message}${elapsed()}`).catch(noop);
663
+ return adapter.edit(thread, id, `${current.message}${elapsed()}`);
399
664
  const text = current.indicator && `${current.indicator}${elapsed()}`;
400
- return adapter.indicate?.(thread, text).catch(noop);
401
- };
665
+ return adapter.indicate?.(thread, text);
666
+ });
402
667
  const timer = setInterval(() => {
403
668
  if (id || current.indicator)
404
669
  render();
@@ -418,18 +683,15 @@ async function openStatusLine(adapter, thread, messageId) {
418
683
  async finish(text) {
419
684
  end();
420
685
  if (id) {
421
- await adapter.edit(thread, id, text).catch(noop);
686
+ await send(() => adapter.edit(thread, id, text));
422
687
  return;
423
688
  }
424
- await adapter.indicate?.(thread, "").catch(noop);
689
+ await send(() => adapter.indicate?.(thread, ""));
425
690
  await adapter.post(thread, text).catch(noop);
426
691
  },
427
692
  async remove() {
428
693
  end();
429
- if (id)
430
- await adapter.remove(thread, id).catch(noop);
431
- else
432
- await adapter.indicate?.(thread, "").catch(noop);
694
+ await send(() => id ? adapter.remove(thread, id) : adapter.indicate?.(thread, ""));
433
695
  },
434
696
  stop: end
435
697
  };
@@ -466,7 +728,7 @@ var threadKeyOf = (thread) => `${thread.channel}:${thread.thread}`;
466
728
  var noop2 = () => {
467
729
  return;
468
730
  };
469
- var isClient = (value) => typeof value === "object" && value !== null && ("runs" in value);
731
+ var isClient2 = (value) => typeof value === "object" && value !== null && ("runs" in value);
470
732
  function describeAnswer(answer) {
471
733
  if (answer.kind === "question")
472
734
  return answer.labels.join(", ");
@@ -511,25 +773,9 @@ function recentIds(limit) {
511
773
  return true;
512
774
  };
513
775
  }
514
- function createBot(options) {
515
- const { adapter } = options;
516
- const log = options.logger ?? console;
517
- const gitterm = isClient(options.gitterm) ? options.gitterm : createGittermClient(options.gitterm);
518
- const runTimeoutMs = options.runTimeoutMs ?? 60 * 60000;
519
- const inputTimeoutMs = options.inputTimeoutMs ?? 30 * 60000;
520
- for (const [name, value, maximum] of [
521
- ["runTimeoutMs", runTimeoutMs, 24 * 60 * 60000],
522
- ["inputTimeoutMs", inputTimeoutMs, 24 * 60 * 60000],
523
- ["maxPendingRequests", options.maxPendingRequests ?? 20, 1000],
524
- ["maxPendingPerUser", options.maxPendingPerUser ?? 3, 1000]
525
- ]) {
526
- if (!Number.isSafeInteger(value) || value <= 0 || value > maximum)
527
- throw new Error(`${name} must be a positive integer no greater than ${maximum}`);
528
- }
776
+ function settingsFor(options) {
529
777
  const defaultRepo = options.repo ? parseRepo(options.repo) : undefined;
530
778
  const extraRepos = (options.repos ?? []).map(parseRepo);
531
- const allowedUsers = options.allowedUsers ? new Set(options.allowedUsers) : undefined;
532
- const model = typeof options.model === "string" ? { id: options.model } : options.model;
533
779
  const channelRepos = new Map(Object.entries(options.channels ?? {}).map(([channel, repo]) => [channel, parseRepo(repo)]));
534
780
  const knownRepos = [
535
781
  ...new Map([defaultRepo, ...channelRepos.values(), ...extraRepos].filter((repo) => Boolean(repo)).map((repo) => [repoKey(repo), repo])).values()
@@ -537,6 +783,32 @@ function createBot(options) {
537
783
  if (knownRepos.length === 0) {
538
784
  throw new Error("Give the bot a `repo`, `repos` to choose from, or a repository per channel.");
539
785
  }
786
+ return {
787
+ options,
788
+ defaultRepo,
789
+ extraRepos,
790
+ channelRepos,
791
+ knownRepos,
792
+ allowedUsers: options.allowedUsers ? new Set(options.allowedUsers) : undefined,
793
+ model: typeof options.model === "string" ? { id: options.model } : options.model
794
+ };
795
+ }
796
+ function createBot(initial) {
797
+ const { adapter } = initial;
798
+ const log = initial.logger ?? console;
799
+ const gitterm = isClient2(initial.gitterm) ? initial.gitterm : createGittermClient2(initial.gitterm);
800
+ const runTimeoutMs = initial.runTimeoutMs ?? 60 * 60000;
801
+ const inputTimeoutMs = initial.inputTimeoutMs ?? 30 * 60000;
802
+ for (const [name, value, maximum] of [
803
+ ["runTimeoutMs", runTimeoutMs, 24 * 60 * 60000],
804
+ ["inputTimeoutMs", inputTimeoutMs, 24 * 60 * 60000],
805
+ ["maxPendingRequests", initial.maxPendingRequests ?? 20, 1000],
806
+ ["maxPendingPerUser", initial.maxPendingPerUser ?? 3, 1000]
807
+ ]) {
808
+ if (!Number.isSafeInteger(value) || value <= 0 || value > maximum)
809
+ throw new Error(`${name} must be a positive integer no greater than ${maximum}`);
810
+ }
811
+ let { options, defaultRepo, extraRepos, channelRepos, knownRepos, allowedUsers, model } = settingsFor(initial);
540
812
  const lock = createLocks();
541
813
  const fresh = recentIds(RECENT_MESSAGES);
542
814
  const active = new Map;
@@ -550,6 +822,7 @@ function createBot(options) {
550
822
  let running = false;
551
823
  let leaseTimer;
552
824
  let leaseId;
825
+ let savedAt;
553
826
  const tasks = new Set;
554
827
  const trackTask = (task) => {
555
828
  tasks.add(task);
@@ -1088,11 +1361,64 @@ function createBot(options) {
1088
1361
  if (!(error instanceof GittermError2 && error.code === "CONFLICT") || waited >= 1e5)
1089
1362
  throw error;
1090
1363
  if (waited === 0)
1091
- log.warn("Another process is running this bot; waiting for it to stop…");
1364
+ log.warn("Another process is running this bot, or one stopped without calling bot.stop() and holds it for up to 90 s; waiting…");
1092
1365
  await new Promise((resolve) => setTimeout(resolve, 5000));
1093
1366
  }
1094
1367
  }
1095
1368
  }
1369
+ const workspaceManager = () => createWorkspaceManager({
1370
+ gitterm,
1371
+ platform: adapter.platform,
1372
+ scope,
1373
+ botId: options.botId,
1374
+ directRetentionMs: options.directRetentionMs,
1375
+ identityPolicy: {
1376
+ allowedUsers: options.allowedUsers,
1377
+ allowGuests: options.allowGuests,
1378
+ allowedChannels: options.allowedChannels,
1379
+ shareChannels: options.shareChannels
1380
+ },
1381
+ connections: options.connections ?? [],
1382
+ env: options.env ?? {},
1383
+ setup: options.setup ?? [],
1384
+ model,
1385
+ instructions: agentInstructions(adapter.displayName, options.instructions),
1386
+ overrides: options.workspace,
1387
+ log
1388
+ });
1389
+ async function reload() {
1390
+ const local = options.localOptions;
1391
+ if (!local)
1392
+ return;
1393
+ try {
1394
+ const saved = await gitterm.bots.self();
1395
+ if (!saved)
1396
+ return;
1397
+ savedAt = saved.updatedAt;
1398
+ if (saved.platform !== adapter.platform) {
1399
+ log.warn(`"${saved.name}" is now a ${saved.platform} bot; run it on that platform.`);
1400
+ return;
1401
+ }
1402
+ const next = settingsFor({
1403
+ ...applySavedConfig(local, saved, log),
1404
+ adapter,
1405
+ localOptions: local
1406
+ });
1407
+ await checkSetup({
1408
+ gitterm,
1409
+ repos: next.knownRepos,
1410
+ connections: next.options.connections ?? [],
1411
+ model: next.model,
1412
+ overrides: next.options.workspace,
1413
+ log
1414
+ });
1415
+ ({ options, defaultRepo, extraRepos, channelRepos, knownRepos, allowedUsers, model } = next);
1416
+ workspaces = workspaceManager();
1417
+ log.info(`Applied the settings saved for "${saved.name}". New sandboxes use them; reset replaces an existing one.`);
1418
+ } catch (error) {
1419
+ log.warn("Could not apply the newly saved settings; keeping the current ones. Save again in GitTerm to retry.", error);
1420
+ }
1421
+ }
1096
1422
  const bot = {
1097
1423
  async start() {
1098
1424
  if (running)
@@ -1106,6 +1432,7 @@ function createBot(options) {
1106
1432
  throw new Error("Load the saved configuration with withSavedConfig() before starting a saved bot");
1107
1433
  }
1108
1434
  if (savedIdentity) {
1435
+ savedAt = savedIdentity.updatedAt;
1109
1436
  const id = randomUUID();
1110
1437
  await claimLease(id);
1111
1438
  leaseId = id;
@@ -1115,7 +1442,11 @@ function createBot(options) {
1115
1442
  if (renewing || shutdown.signal.aborted)
1116
1443
  return;
1117
1444
  renewing = true;
1118
- gitterm.bots.acquireLease(id).then(() => failures = 0).catch((error) => {
1445
+ gitterm.bots.acquireLease(id).then(async (lease) => {
1446
+ failures = 0;
1447
+ if (lease.updatedAt && lease.updatedAt !== savedAt)
1448
+ await reload();
1449
+ }).catch((error) => {
1119
1450
  const lost = error instanceof GittermError2 && error.code === "CONFLICT";
1120
1451
  if (!lost && ++failures < 2)
1121
1452
  return;
@@ -1154,26 +1485,7 @@ function createBot(options) {
1154
1485
  accepts: servesChannel
1155
1486
  });
1156
1487
  scope = started.scope;
1157
- workspaces = createWorkspaceManager({
1158
- gitterm,
1159
- platform: adapter.platform,
1160
- scope,
1161
- botId: options.botId,
1162
- directRetentionMs: options.directRetentionMs,
1163
- identityPolicy: {
1164
- allowedUsers: options.allowedUsers,
1165
- allowGuests: options.allowGuests,
1166
- allowedChannels: options.allowedChannels,
1167
- shareChannels: options.shareChannels
1168
- },
1169
- connections: options.connections ?? [],
1170
- env: options.env ?? {},
1171
- setup: options.setup ?? [],
1172
- model,
1173
- instructions: agentInstructions(adapter.displayName, options.instructions),
1174
- overrides: options.workspace,
1175
- log
1176
- });
1488
+ workspaces = workspaceManager();
1177
1489
  await recover();
1178
1490
  markReady();
1179
1491
  log.info(`GitTerm ${adapter.displayName} bot is running for ${knownRepos.map(repoLabel).join(", ")}.`);
@@ -1236,256 +1548,6 @@ async function downloadImage(url, options) {
1236
1548
  reader.releaseLock();
1237
1549
  }
1238
1550
  }
1239
- // src/saved.ts
1240
- import {
1241
- createGittermClient as createGittermClient2
1242
- } from "@gitterm/sdk";
1243
-
1244
- // src/env.ts
1245
- import { existsSync, readFileSync } from "node:fs";
1246
- import { parseArgs, parseEnv } from "node:util";
1247
- var read = (env, name) => env[name]?.trim() || undefined;
1248
- function positive(env, name) {
1249
- const value = read(env, name);
1250
- if (!value)
1251
- return;
1252
- const parsed = Number(value);
1253
- if (!Number.isInteger(parsed) || parsed <= 0)
1254
- throw new Error(`${name} must be a positive integer`);
1255
- return parsed;
1256
- }
1257
- function parseChannels(value) {
1258
- const channels = {};
1259
- for (const entry of value.split(",")) {
1260
- const [channel, repo] = entry.split("=", 2).map((part) => part.trim());
1261
- if (!channel || !repo) {
1262
- throw new Error(`GITTERM_BOT_CHANNELS entries look like <channel id>=<repository url>`);
1263
- }
1264
- channels[channel] = repo;
1265
- }
1266
- return channels;
1267
- }
1268
- var list = (value) => value.split(",").map((entry) => entry.trim()).filter(Boolean);
1269
- function readSandboxEnv(path) {
1270
- if (!path)
1271
- return;
1272
- try {
1273
- const parsed = parseEnv(readFileSync(path, "utf8"));
1274
- return Object.fromEntries(Object.entries(parsed).filter((entry) => entry[1] !== undefined));
1275
- } catch (error) {
1276
- throw new Error(`Could not read the sandbox environment file ${path}`, { cause: error });
1277
- }
1278
- }
1279
- function readInstructionsFile(path) {
1280
- if (!path)
1281
- return;
1282
- try {
1283
- return readFileSync(path, "utf8").trim() || undefined;
1284
- } catch (error) {
1285
- throw new Error(`Could not read the instructions file ${path}`, { cause: error });
1286
- }
1287
- }
1288
- function botOptionsFromEnv(env = process.env) {
1289
- const connections = read(env, "GITTERM_BOT_CONNECTIONS");
1290
- const provider = read(env, "GITTERM_BOT_PROVIDER");
1291
- const channels = read(env, "GITTERM_BOT_CHANNELS");
1292
- const options = {};
1293
- const botId = read(env, "GITTERM_BOT_ID");
1294
- if (botId)
1295
- options.botId = botId;
1296
- const allowedChannels = read(env, "GITTERM_BOT_ALLOWED_CHANNELS");
1297
- if (allowedChannels)
1298
- options.allowedChannels = list(allowedChannels);
1299
- const approvers = read(env, "GITTERM_BOT_APPROVERS");
1300
- if (approvers)
1301
- options.approvers = list(approvers);
1302
- if (read(env, "GITTERM_BOT_ALLOW_ALWAYS") === "true")
1303
- options.allowAlways = true;
1304
- if (read(env, "GITTERM_BOT_SHARE_CHANNELS") === "true")
1305
- options.shareChannels = true;
1306
- const maxPendingRequests = positive(env, "GITTERM_BOT_MAX_PENDING");
1307
- if (maxPendingRequests)
1308
- options.maxPendingRequests = maxPendingRequests;
1309
- const maxPendingPerUser = positive(env, "GITTERM_BOT_MAX_PENDING_PER_USER");
1310
- if (maxPendingPerUser)
1311
- options.maxPendingPerUser = maxPendingPerUser;
1312
- const repo = read(env, "GITTERM_BOT_REPO");
1313
- if (repo)
1314
- options.repo = repo;
1315
- if (channels)
1316
- options.channels = parseChannels(channels);
1317
- if (connections)
1318
- options.connections = list(connections);
1319
- const repos = read(env, "GITTERM_BOT_REPOS");
1320
- if (repos)
1321
- options.repos = list(repos);
1322
- const allowedUsers = read(env, "GITTERM_BOT_ALLOWED_USERS");
1323
- if (allowedUsers)
1324
- options.allowedUsers = list(allowedUsers);
1325
- if (read(env, "GITTERM_BOT_ALLOW_GUESTS") === "true")
1326
- options.allowGuests = true;
1327
- const setup = read(env, "GITTERM_BOT_SETUP");
1328
- if (setup)
1329
- options.setup = [setup];
1330
- const sandboxEnv = readSandboxEnv(read(env, "GITTERM_BOT_SANDBOX_ENV_FILE"));
1331
- if (sandboxEnv)
1332
- options.env = sandboxEnv;
1333
- const model = read(env, "GITTERM_BOT_MODEL");
1334
- const credential = read(env, "GITTERM_BOT_MODEL_CREDENTIAL");
1335
- const apiKey = read(env, "GITTERM_BOT_MODEL_API_KEY");
1336
- if ((credential || apiKey) && !model) {
1337
- throw new Error("GITTERM_BOT_MODEL_CREDENTIAL and GITTERM_BOT_MODEL_API_KEY need GITTERM_BOT_MODEL");
1338
- }
1339
- if (model) {
1340
- options.model = {
1341
- id: model,
1342
- ...credential ? { credential } : {},
1343
- ...apiKey ? { apiKey } : {}
1344
- };
1345
- }
1346
- const instructions = [
1347
- read(env, "GITTERM_BOT_INSTRUCTIONS"),
1348
- readInstructionsFile(read(env, "GITTERM_BOT_INSTRUCTIONS_FILE"))
1349
- ].filter(Boolean).join(`
1350
-
1351
- `);
1352
- if (instructions)
1353
- options.instructions = instructions;
1354
- const githubToken = read(env, "GITTERM_BOT_GITHUB_TOKEN");
1355
- if (provider || githubToken) {
1356
- options.workspace = {
1357
- ...provider ? { provider: { type: provider } } : {},
1358
- ...githubToken ? { repositoryCredentials: { token: githubToken } } : {}
1359
- };
1360
- }
1361
- const stateFile = read(env, "GITTERM_BOT_STATE_FILE");
1362
- if (stateFile)
1363
- options.stateFile = stateFile;
1364
- const runTimeoutMinutes = positive(env, "GITTERM_BOT_RUN_TIMEOUT_MINUTES");
1365
- if (runTimeoutMinutes)
1366
- options.runTimeoutMs = runTimeoutMinutes * 60000;
1367
- return options;
1368
- }
1369
- function cliOptions(argv = process.argv.slice(2)) {
1370
- if (existsSync(".env") && typeof process.loadEnvFile === "function")
1371
- process.loadEnvFile(".env");
1372
- const { values, positionals } = parseArgs({
1373
- args: argv,
1374
- allowPositionals: true,
1375
- options: {
1376
- repo: { type: "string" },
1377
- model: { type: "string" },
1378
- "instructions-file": { type: "string" },
1379
- help: { type: "boolean", short: "h" }
1380
- }
1381
- });
1382
- const flags = {
1383
- GITTERM_BOT_REPO: values.repo,
1384
- GITTERM_BOT_MODEL: values.model,
1385
- GITTERM_BOT_INSTRUCTIONS_FILE: values["instructions-file"]
1386
- };
1387
- const env = { ...process.env };
1388
- for (const [name, value] of Object.entries(flags))
1389
- if (value)
1390
- env[name] = value;
1391
- return { command: values.help ? "help" : positionals[0], options: botOptionsFromEnv(env) };
1392
- }
1393
- var BOT_ENV_HELP = ` Saved policy loads at startup; local settings cannot broaden its users, guests, or channels.
1394
-
1395
- GITTERM_API_TOKEN The bot's GitTerm token (dashboard → Bots creates one)
1396
- GITTERM_SERVER_URL Self-hosted GitTerm API URL (default: hosted)
1397
- GITTERM_BOT_REPO Repository for every channel: https://github.com/acme/app[#branch]
1398
- GITTERM_BOT_REPOS More repositories people can name in a thread: url,url
1399
- GITTERM_BOT_CHANNELS Per-channel repositories: <channel id>=<url>,…
1400
- GITTERM_BOT_MODEL provider/model, e.g. anthropic/claude-sonnet-5-5
1401
- GITTERM_BOT_MODEL_CREDENTIAL Saved credential label (default: the provider's default)
1402
- GITTERM_BOT_MODEL_API_KEY Or a model API key for this bot only
1403
- GITTERM_BOT_GITHUB_TOKEN Your own GitHub token instead of a GitTerm GitHub connection
1404
- GITTERM_BOT_CONNECTIONS Tools besides GitHub, by name: Linear,Sentry
1405
- GITTERM_BOT_ALLOWED_USERS Only these user ids may use the bot: U012,U034
1406
- GITTERM_BOT_ID Names an unsaved bot's sandboxes (saved bots use their own id)
1407
- GITTERM_BOT_ALLOWED_CHANNELS Only these channel ids, whatever the repository routing
1408
- GITTERM_BOT_APPROVERS Permission approver ids (default: requester only)
1409
- GITTERM_BOT_ALLOW_ALWAYS true enables persistent approvals (default: disabled)
1410
- GITTERM_BOT_SHARE_CHANNELS true opts equally trusted channels into a shared sandbox
1411
- GITTERM_BOT_MAX_PENDING[_PER_USER] Queue bounds (defaults: 20 total, 3 per user)
1412
- GITTERM_BOT_ALLOW_GUESTS true lets guests and external people use the bot
1413
- GITTERM_BOT_INSTRUCTIONS[_FILE] What the agent should know and how to behave
1414
- GITTERM_BOT_SETUP Command run before the agent starts, e.g. "pnpm install"
1415
- GITTERM_BOT_SANDBOX_ENV_FILE dotenv file of variables for the sandbox (test credentials)
1416
- GITTERM_BOT_PROVIDER Compute provider for new sandboxes (railway, e2b, …)
1417
- GITTERM_BOT_STATE_FILE, GITTERM_BOT_RUN_TIMEOUT_MINUTES`;
1418
- var hasRepository = (options) => Boolean(options.repo || options.repos?.length || Object.keys(options.channels ?? {}).length);
1419
-
1420
- // src/saved.ts
1421
- var isClient2 = (value) => typeof value === "object" && value !== null && ("runs" in value);
1422
- async function withSavedConfig(options, platform, log = console) {
1423
- const gitterm = isClient2(options.gitterm) ? options.gitterm : createGittermClient2(options.gitterm);
1424
- const base = { ...options, gitterm };
1425
- const saved = await gitterm.bots.self();
1426
- if (!saved)
1427
- return base;
1428
- if (saved.platform !== platform) {
1429
- throw new Error(`This GitTerm token belongs to the ${saved.platform} bot "${saved.name}"; run that bot with it`);
1430
- }
1431
- const merged = { ...base };
1432
- const localRepos = [
1433
- options.repo,
1434
- ...options.repos ?? [],
1435
- ...Object.values(options.channels ?? {})
1436
- ].filter((repo) => repo !== undefined);
1437
- if (localRepos.some((repo) => repoKey(parseRepo(repo)) !== repoKey(parseRepo(saved.repo)))) {
1438
- throw new Error("Local repository routing exceeds the saved bot policy. Change its repository in GitTerm first.");
1439
- }
1440
- const localModel = typeof options.model === "string" ? options.model : options.model?.id;
1441
- if (localModel && localModel !== saved.model)
1442
- throw new Error("Local model exceeds the saved bot policy. Change its model in GitTerm first.");
1443
- if (options.connections?.some((reference) => !saved.connections.includes(reference))) {
1444
- throw new Error("Local connections exceed the saved bot policy. Select them in GitTerm first.");
1445
- }
1446
- merged.botId = saved.id;
1447
- if (saved.channels.length) {
1448
- merged.allowedChannels = options.allowedChannels ? options.allowedChannels.filter((id) => saved.channels.includes(id)) : saved.channels;
1449
- }
1450
- if (!hasRepository(options)) {
1451
- if (saved.channels.length) {
1452
- merged.channels = Object.fromEntries(saved.channels.map((id) => [id, saved.repo]));
1453
- } else {
1454
- merged.repo = saved.repo;
1455
- }
1456
- }
1457
- merged.model ??= {
1458
- id: saved.model,
1459
- ...saved.credential ? { credential: saved.credential } : {}
1460
- };
1461
- merged.connections ??= saved.connections;
1462
- if (saved.provider && !options.workspace?.provider) {
1463
- merged.workspace = {
1464
- ...options.workspace,
1465
- provider: { type: saved.provider }
1466
- };
1467
- }
1468
- if (saved.allowedUsers.length) {
1469
- merged.allowedUsers = options.allowedUsers ? options.allowedUsers.filter((id) => saved.allowedUsers.includes(id)) : saved.allowedUsers;
1470
- }
1471
- merged.allowGuests = saved.allowGuests && (options.allowGuests ?? true);
1472
- if (saved.instructions)
1473
- merged.instructions ??= saved.instructions;
1474
- if (saved.setup)
1475
- merged.setup ??= [saved.setup];
1476
- if (saved.githubAccess === "token" && !merged.workspace?.repositoryCredentials) {
1477
- log.warn(`"${saved.name}" uses your own GitHub token, but GITTERM_BOT_GITHUB_TOKEN is not set. Public repositories still work.`);
1478
- }
1479
- log.info(`Using the settings saved for "${saved.name}" in GitTerm.`);
1480
- log.info("Effective bot authorization", {
1481
- botId: saved.id,
1482
- source: "saved policy, intersected with local restrictions",
1483
- channels: merged.allowedChannels ?? "all invited channels",
1484
- users: merged.allowedUsers ?? "channel members",
1485
- allowGuests: merged.allowGuests
1486
- });
1487
- return merged;
1488
- }
1489
1551
  // src/markdown.ts
1490
1552
  var TABLE_SEPARATOR = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/;
1491
1553
  function renderTable(lines) {
package/dist/saved.d.ts CHANGED
@@ -4,7 +4,10 @@ type Options = Omit<BotOptions, "adapter">;
4
4
  /**
5
5
  * Adds the settings saved for this bot in the GitTerm dashboard (Bots), found by its API token.
6
6
  * Routing can be overridden locally; saved authorization is an upper bound. Without saved settings,
7
- * for example a token that isn't a bot's, the options come back unchanged.
7
+ * for example a token that isn't a bot's, the options come back unchanged. The options given are
8
+ * kept as `localOptions`, so the running bot can apply settings saved later the same way.
8
9
  */
9
10
  export declare function withSavedConfig(options: Options, platform: SavedBot["platform"], log?: BotLogger): Promise<Options>;
11
+ /** Saved settings on top of local options; throws when the local options exceed them. */
12
+ export declare function applySavedConfig(options: Options, saved: SavedBot, log?: BotLogger): Options;
10
13
  export {};
package/dist/types.d.ts CHANGED
@@ -208,6 +208,11 @@ export type BotOptions = {
208
208
  /** How long a question or permission prompt waits for an answer. Default 30 min. */
209
209
  inputTimeoutMs?: number;
210
210
  logger?: BotLogger;
211
+ /**
212
+ * Set by `withSavedConfig`: the options before saved settings were added. A running bot applies
213
+ * settings saved later in the dashboard to these, without a restart.
214
+ */
215
+ localOptions?: Omit<BotOptions, "adapter" | "localOptions">;
211
216
  };
212
217
  export type Bot = {
213
218
  start(): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitterm/bot",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Chat-platform-agnostic engine for GitTerm agent bots: one sandbox per repository, one agent session per thread",
5
5
  "keywords": [
6
6
  "agent",
@@ -33,7 +33,7 @@
33
33
  "check-types": "tsc --noEmit"
34
34
  },
35
35
  "dependencies": {
36
- "@gitterm/sdk": "^0.10.0"
36
+ "@gitterm/sdk": "^0.11.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/bun": "^1.2.6",