@0xmaxma/claude-gateway 1.3.3 → 1.3.5

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.
Files changed (54) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/runner.d.ts +26 -1
  3. package/dist/agent/runner.d.ts.map +1 -1
  4. package/dist/agent/runner.js +264 -68
  5. package/dist/agent/runner.js.map +1 -1
  6. package/dist/api/gateway-router.d.ts +10 -0
  7. package/dist/api/gateway-router.d.ts.map +1 -1
  8. package/dist/api/gateway-router.js +261 -7
  9. package/dist/api/gateway-router.js.map +1 -1
  10. package/dist/api/router.d.ts.map +1 -1
  11. package/dist/api/router.js +52 -32
  12. package/dist/api/router.js.map +1 -1
  13. package/dist/session/process.d.ts +8 -0
  14. package/dist/session/process.d.ts.map +1 -1
  15. package/dist/session/process.js +28 -0
  16. package/dist/session/process.js.map +1 -1
  17. package/dist/session/store.d.ts.map +1 -1
  18. package/dist/session/store.js +46 -0
  19. package/dist/session/store.js.map +1 -1
  20. package/dist/shell/claude-pty-shell.js +211 -4
  21. package/dist/shell/claude-pty-shell.js.map +1 -1
  22. package/dist/shell/emitter.d.ts +13 -0
  23. package/dist/shell/emitter.d.ts.map +1 -1
  24. package/dist/shell/emitter.js +16 -0
  25. package/dist/shell/emitter.js.map +1 -1
  26. package/dist/shell/menu-cancel.d.ts +50 -0
  27. package/dist/shell/menu-cancel.d.ts.map +1 -0
  28. package/dist/shell/menu-cancel.js +62 -0
  29. package/dist/shell/menu-cancel.js.map +1 -0
  30. package/dist/shell/pty-serialize.d.ts +23 -0
  31. package/dist/shell/pty-serialize.d.ts.map +1 -0
  32. package/dist/shell/pty-serialize.js +131 -0
  33. package/dist/shell/pty-serialize.js.map +1 -0
  34. package/dist/shell/pty-stream-registry.d.ts +59 -0
  35. package/dist/shell/pty-stream-registry.d.ts.map +1 -0
  36. package/dist/shell/pty-stream-registry.js +279 -0
  37. package/dist/shell/pty-stream-registry.js.map +1 -0
  38. package/dist/shell/screen.d.ts +43 -0
  39. package/dist/shell/screen.d.ts.map +1 -1
  40. package/dist/shell/screen.js +91 -1
  41. package/dist/shell/screen.js.map +1 -1
  42. package/dist/types.d.ts +1 -0
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/ui/web-ui.d.ts +2 -2
  45. package/dist/ui/web-ui.d.ts.map +1 -1
  46. package/dist/ui/web-ui.js +615 -114
  47. package/dist/ui/web-ui.js.map +1 -1
  48. package/mcp/tools/discord/module.ts +88 -1
  49. package/mcp/tools/discord/outbound.ts +36 -0
  50. package/mcp/tools/discord/types.ts +2 -0
  51. package/mcp/tools/telegram/menu-parser.ts +37 -0
  52. package/mcp/tools/telegram/receiver-server.ts +83 -0
  53. package/mcp/tools/telegram/typing.ts +7 -1
  54. package/package.json +4 -2
package/README.md CHANGED
@@ -29,7 +29,7 @@ A self-hosted multi-agent gateway for Claude Code. Connect Claude agents to Tele
29
29
  - **App Store** — install, update, and host Docker-compose apps on the gateway; apps get a reverse proxy at `/app/:name/:portName/*`, optional Unix socket bridge for host scripts, and optional AI agent injection
30
30
  - **Self-update API** — check for newer versions of `claude-gateway` and `claude-code` and trigger an update via a single API call; no SSH or shell access needed
31
31
  - **Session persistence** — conversation history saved and restored across restarts
32
- - **PTY backend** — optional interactive pseudo-terminal backend (`gateway.headless: false`) for tools that require a real TTY; uses `TranscriptTailer` for reliable output instead of ANSI parsing; app-agents always stay headless
32
+ - **PTY shell (wrap-shell mode)** — optional interactive pseudo-terminal backend (`gateway.headless: false`) for tools that require a real TTY; includes a live browser viewer (xterm.js) and a `/api/v1/sessions/:sessionId/screen` endpoint that returns the visible screen as plain text — agents can poll it to detect hang states, menus, or unexpected output without parsing ANSI escape codes; app-agents always stay headless
33
33
 
34
34
  ---
35
35
 
@@ -29,6 +29,8 @@ export declare class AgentRunner extends EventEmitter {
29
29
  private readonly sessionSpawnLocks;
30
30
  private readonly pendingImagePaths;
31
31
  private readonly pendingApiAttachments;
32
+ private readonly sessionHistory;
33
+ private readonly apiChatIds;
32
34
  private skillRegistry;
33
35
  private readonly configPath;
34
36
  private readonly historyDb;
@@ -133,6 +135,13 @@ export declare class AgentRunner extends EventEmitter {
133
135
  private writeTypingDone;
134
136
  private triggerSummaryAndRestart;
135
137
  private writeAutoForward;
138
+ /**
139
+ * Signal the channel receiver to render an interactive menu as native UI
140
+ * (inline buttons on Telegram/Discord). The receiver maps each option to a
141
+ * `choice:N` callback whose tap arrives back as a normal "N" message — the
142
+ * same as the user typing the number.
143
+ */
144
+ private writeMenuForward;
136
145
  private startIdleCleaner;
137
146
  start(): Promise<void>;
138
147
  updateAgentConfig(newConfig: AgentConfig): void;
@@ -145,6 +154,17 @@ export declare class AgentRunner extends EventEmitter {
145
154
  private _startCleanupScheduler;
146
155
  restart(): Promise<void>;
147
156
  isRunning(): boolean;
157
+ getSessionsSummary(): Array<{
158
+ chatId: string;
159
+ sessionId: string;
160
+ source: string;
161
+ mode: string;
162
+ model: string;
163
+ isRunning: boolean;
164
+ spawnedAt: number;
165
+ uptimeSec: number;
166
+ tokens: number;
167
+ }>;
148
168
  /**
149
169
  * Send a message to an API session and wait for the response.
150
170
  *
@@ -203,7 +223,12 @@ export declare class AgentRunner extends EventEmitter {
203
223
  name: string;
204
224
  }>>;
205
225
  listSessionsForChat(chatId: string, channel: 'telegram' | 'discord'): Promise<import('../types').SessionIndex>;
206
- executeApiCommand(sessionId: string, chatId: string, command: string): Promise<Record<string, unknown>>;
226
+ executeApiCommand(sessionId: string, chatId: string, command: string, opts?: {
227
+ skipPersist?: boolean;
228
+ }): Promise<{
229
+ result: Record<string, unknown>;
230
+ responseText: string;
231
+ }>;
207
232
  setModel(newModel: string): Promise<void>;
208
233
  listApiSessions(chatId: string): Promise<import('../types').SessionIndex>;
209
234
  createApiSession(chatId: string, prompt?: string, name?: string): Promise<import('../types').SessionMeta>;
@@ -1 +1 @@
1
- {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../src/agent/runner.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../src/agent/runner.ts"],"names":[],"mappings":"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"}
@@ -128,6 +128,12 @@ class AgentRunner extends events_1.EventEmitter {
128
128
  this.pendingImagePaths = new Map();
129
129
  // Buffers attachment file paths registered via api_reply tool for the current API session turn.
130
130
  this.pendingApiAttachments = new Map();
131
+ // Session history ring-buffer (last 10 spawned, newest first)
132
+ this.sessionHistory = [];
133
+ // For API sessions the map key is the sessionId, so the real caller chatId
134
+ // (e.g. the app/agent identifier) is not captured at spawn. Record it here
135
+ // keyed by sessionId so the dashboard can show the actual chat id.
136
+ this.apiChatIds = new Map();
131
137
  // Skill registry for detecting /skill-name commands in user messages
132
138
  this.skillRegistry = { skills: new Map() };
133
139
  // Cancel function for the daily history cleanup timer
@@ -619,6 +625,9 @@ class AgentRunner extends events_1.EventEmitter {
619
625
  // Auto-forward result text to channel if agent didn't call reply tool.
620
626
  let replyCalled = false;
621
627
  let replyToolUseId = null; // track id to detect failed tool calls
628
+ // Set when an interactive-menu prompt was rendered to the channel this turn,
629
+ // so the result's plain-text auto-forward is skipped (no duplicate message).
630
+ let menuSentThisTurn = false;
622
631
  let typingDoneTimer = null;
623
632
  const TYPING_DONE_DELAY_MS = 3000;
624
633
  const replyToolName = source === 'discord' ? 'mcp__gateway__discord_reply' : 'mcp__gateway__telegram_reply';
@@ -667,6 +676,31 @@ class AgentRunner extends events_1.EventEmitter {
667
676
  }
668
677
  }
669
678
  }
679
+ // Interactive select menu blocking the PTY: render it as channel-native
680
+ // UI (inline buttons). This output handler only runs for Telegram/Discord
681
+ // sessions; API uses a separate path and falls back to the result text's
682
+ // numbered list (no buttons), as intended.
683
+ if (obj['type'] === 'system' && obj['subtype'] === 'menu_prompt') {
684
+ const promptText = typeof obj['prompt'] === 'string' ? obj['prompt'] : '';
685
+ const rawOptions = Array.isArray(obj['options']) ? obj['options'] : [];
686
+ const options = rawOptions
687
+ .map((o) => ({ label: typeof o?.label === 'string' ? o.label : '' }))
688
+ .filter((o) => o.label);
689
+ if (promptText && options.length) {
690
+ this.writeMenuForward(mapKey, promptText, options);
691
+ menuSentThisTurn = true;
692
+ // Persist the question to chat history so it's visible in the transcript.
693
+ const channelSrc = this.channelSourceMap.get(mapKey) ?? 'telegram';
694
+ this.historyDb.insertMessage({
695
+ chatId: `${channelSrc}-${mapKey}`,
696
+ sessionId: actualSessionId,
697
+ source: channelSrc,
698
+ role: 'assistant',
699
+ content: promptText,
700
+ ts: Date.now(),
701
+ });
702
+ }
703
+ }
670
704
  if (obj['type'] === 'result') {
671
705
  proc.setProcessing(false);
672
706
  // Telegram: accumulate image size via stat of local file (FIFO, one per turn)
@@ -699,7 +733,9 @@ class AgentRunner extends events_1.EventEmitter {
699
733
  // Suppress the corrupted-thinking 400: the session auto-respawns to recover,
700
734
  // so the raw API error must not reach the user's chat or the history DB.
701
735
  const isThinkingCorruption = obj['is_error'] === true && process_1.SessionProcess.isThinkingCorruptionError(resultText);
702
- if (resultText.trim() && !proc.queryMode && !replyCalled && !isThinkingCorruption) {
736
+ // Skip when a menu was rendered to buttons this turn — the result
737
+ // text is the same numbered list and would duplicate the menu message.
738
+ if (resultText.trim() && !proc.queryMode && !replyCalled && !isThinkingCorruption && !menuSentThisTurn) {
703
739
  const text = resultText.trim();
704
740
  const channelSrcForResult = this.channelSourceMap.get(mapKey) ?? 'telegram';
705
741
  // Persist assistant reply to permanent history DB
@@ -721,6 +757,7 @@ class AgentRunner extends events_1.EventEmitter {
721
757
  }
722
758
  replyCalled = false; // reset for next turn
723
759
  replyToolUseId = null;
760
+ menuSentThisTurn = false;
724
761
  // Delay typing done — agent may continue with more work
725
762
  typingDoneTimer = setTimeout(() => {
726
763
  this.writeTypingDone(mapKey);
@@ -775,6 +812,9 @@ class AgentRunner extends events_1.EventEmitter {
775
812
  });
776
813
  }
777
814
  this.sessions.set(mapKey, proc);
815
+ this.sessionHistory.unshift({ chatId: mapKey, sessionId: proc.sessionId, source: proc.source, mode: proc.backend, model: proc.model, spawnedAt: proc.spawnedAt });
816
+ if (this.sessionHistory.length > 10)
817
+ this.sessionHistory.length = 10;
778
818
  if (source === 'telegram' || source === 'discord') {
779
819
  this.channelSourceMap.set(mapKey, source);
780
820
  }
@@ -1112,6 +1152,27 @@ class AgentRunner extends events_1.EventEmitter {
1112
1152
  // Non-fatal
1113
1153
  }
1114
1154
  }
1155
+ /**
1156
+ * Signal the channel receiver to render an interactive menu as native UI
1157
+ * (inline buttons on Telegram/Discord). The receiver maps each option to a
1158
+ * `choice:N` callback whose tap arrives back as a normal "N" message — the
1159
+ * same as the user typing the number.
1160
+ */
1161
+ writeMenuForward(chatId, text, options) {
1162
+ const typingDir = this.getTypingDir(chatId);
1163
+ try {
1164
+ fs.mkdirSync(typingDir, { recursive: true });
1165
+ // Write atomically (tmp + rename): the receiver polls this directory every
1166
+ // second, so a non-atomic write could be read mid-flush as truncated JSON.
1167
+ const finalPath = path.join(typingDir, `${chatId}.menu`);
1168
+ const tmpPath = `${finalPath}.tmp`;
1169
+ fs.writeFileSync(tmpPath, JSON.stringify({ text, options }));
1170
+ fs.renameSync(tmpPath, finalPath);
1171
+ }
1172
+ catch {
1173
+ // Non-fatal — the result text still carries the numbered list as a fallback.
1174
+ }
1175
+ }
1115
1176
  startIdleCleaner() {
1116
1177
  this.idleCleanerTimer = setInterval(async () => {
1117
1178
  for (const [id, proc] of this.sessions) {
@@ -1220,6 +1281,34 @@ class AgentRunner extends events_1.EventEmitter {
1220
1281
  isRunning() {
1221
1282
  return this.receiver?.isRunning() ?? false;
1222
1283
  }
1284
+ getSessionsSummary() {
1285
+ const now = Date.now();
1286
+ // A single logical session can appear multiple times in the ring buffer: a
1287
+ // restart / model-switch / error-recovery respawn pushes a fresh entry with
1288
+ // the SAME sessionId but a new spawnedAt. History is newest-first (unshift),
1289
+ // so the first occurrence of a (source, sessionId, chatId) is the current
1290
+ // incarnation — collapse to it so the dashboard shows one row per session
1291
+ // instead of duplicating it.
1292
+ const seen = new Set();
1293
+ const out = [];
1294
+ for (const e of this.sessionHistory) {
1295
+ const dedupKey = `${e.source}:${e.sessionId}:${e.chatId}`;
1296
+ if (seen.has(dedupKey))
1297
+ continue;
1298
+ seen.add(dedupKey);
1299
+ const proc = this.sessions.get(e.chatId);
1300
+ // Running only when the live process IS this incarnation: a stale entry
1301
+ // from before a respawn must not borrow the new process's running state.
1302
+ const isRunning = !!proc && proc.spawnedAt === e.spawnedAt;
1303
+ // For API sessions the ring-buffer chatId equals the sessionId (the map key);
1304
+ // surface the real caller chatId instead when we have it.
1305
+ const chatId = e.source === 'api' ? (this.apiChatIds.get(e.sessionId) ?? e.chatId) : e.chatId;
1306
+ // Live context-window token usage from the running process (0 when stopped).
1307
+ const tokens = isRunning ? (proc?.totalTokens ?? 0) : 0;
1308
+ out.push({ ...e, chatId, isRunning, uptimeSec: Math.floor((now - e.spawnedAt) / 1000), tokens });
1309
+ }
1310
+ return out;
1311
+ }
1223
1312
  /**
1224
1313
  * Send a message to an API session and wait for the response.
1225
1314
  *
@@ -1239,6 +1328,7 @@ class AgentRunner extends events_1.EventEmitter {
1239
1328
  });
1240
1329
  // Use model from request body if provided, otherwise use agent default
1241
1330
  const session = await this.getOrSpawnSession(sessionId, 'api', undefined, opts.model);
1331
+ this.apiChatIds.set(sessionId, chatId);
1242
1332
  // Promote UI-uploaded files from staging to permanent per-session storage
1243
1333
  const finalMediaFiles = opts.mediaFiles?.length
1244
1334
  ? await promoteUiUploads(this.agentsBaseDir, this.agentConfig.id, sessionId, opts.mediaFiles, this.logger)
@@ -1302,8 +1392,9 @@ class AgentRunner extends events_1.EventEmitter {
1302
1392
  const done = (result) => {
1303
1393
  cleanup();
1304
1394
  const attachments = this.popApiAttachments(sessionId);
1305
- // Persist assistant reply
1306
- if (result.trim()) {
1395
+ // Persist assistant reply. Image-only replies (empty text but attachments
1396
+ // present) must also persist — otherwise the screenshot vanishes from history.
1397
+ if (result.trim() || attachments.length) {
1307
1398
  const apiAssistantTs = Date.now();
1308
1399
  this.sessionStore
1309
1400
  .appendMessage(this.agentConfig.id, sessionId, {
@@ -1318,6 +1409,7 @@ class AgentRunner extends events_1.EventEmitter {
1318
1409
  source: 'api',
1319
1410
  role: 'assistant',
1320
1411
  content: result.trim(),
1412
+ mediaFiles: attachments.length ? attachments.map((a) => `media/${a.relPath}`) : undefined,
1321
1413
  ts: apiAssistantTs,
1322
1414
  });
1323
1415
  }
@@ -1409,6 +1501,7 @@ class AgentRunner extends events_1.EventEmitter {
1409
1501
  });
1410
1502
  // Use model from request body if provided, otherwise use agent default
1411
1503
  const session = await this.getOrSpawnSession(sessionId, 'api', undefined, opts.model);
1504
+ this.apiChatIds.set(sessionId, chatId);
1412
1505
  // Promote UI-uploaded files from staging to permanent per-session storage
1413
1506
  const finalMediaFilesStream = opts.mediaFiles?.length
1414
1507
  ? await promoteUiUploads(this.agentsBaseDir, this.agentConfig.id, sessionId, opts.mediaFiles, this.logger)
@@ -1449,8 +1542,10 @@ class AgentRunner extends events_1.EventEmitter {
1449
1542
  settled = true;
1450
1543
  cleanup();
1451
1544
  const attachments = this.popApiAttachments(sessionId);
1452
- // Persist assistant reply regardless of whether the SSE client is still connected
1453
- if (result.trim()) {
1545
+ // Persist assistant reply regardless of whether the SSE client is still connected.
1546
+ // Image-only replies (empty text but attachments present) must also persist —
1547
+ // otherwise the screenshot vanishes from history once the stream ends.
1548
+ if (result.trim() || attachments.length) {
1454
1549
  const streamAssistantTs = Date.now();
1455
1550
  this.sessionStore
1456
1551
  .appendMessage(this.agentConfig.id, sessionId, {
@@ -1465,6 +1560,7 @@ class AgentRunner extends events_1.EventEmitter {
1465
1560
  source: 'api',
1466
1561
  role: 'assistant',
1467
1562
  content: result.trim(),
1563
+ mediaFiles: attachments.length ? attachments.map((a) => `media/${a.relPath}`) : undefined,
1468
1564
  ts: streamAssistantTs,
1469
1565
  });
1470
1566
  }
@@ -1621,11 +1717,13 @@ class AgentRunner extends events_1.EventEmitter {
1621
1717
  if (!fs.existsSync(absPath))
1622
1718
  return null;
1623
1719
  const rel = absPath.slice(mediaRoot.length).replace(/\\/g, '/');
1624
- return { type: 'image', url: `/v1/agents/${encodeURIComponent(this.agentConfig.id)}/media/${rel}` };
1720
+ return { type: 'image', url: `/v1/agents/${encodeURIComponent(this.agentConfig.id)}/media/${rel}`, relPath: rel };
1625
1721
  })
1626
1722
  .filter((a) => a !== null);
1627
1723
  }
1628
1724
  cleanupApiSessionMediaDir(sessionId, source) {
1725
+ // Always evict the chat-id mapping — safe no-op for non-api sessions.
1726
+ this.apiChatIds.delete(sessionId);
1629
1727
  if (source !== 'api')
1630
1728
  return;
1631
1729
  const mediaDir = path.join(this.agentsBaseDir, this.agentConfig.id, 'media', `api-${sessionId}`);
@@ -1651,73 +1749,171 @@ class AgentRunner extends events_1.EventEmitter {
1651
1749
  async listSessionsForChat(chatId, channel) {
1652
1750
  return this.sessionStore.listSessions(this.agentConfig.id, chatId, channel);
1653
1751
  }
1654
- async executeApiCommand(sessionId, chatId, command) {
1752
+ async executeApiCommand(sessionId, chatId, command, opts) {
1655
1753
  const agentId = this.agentConfig.id;
1656
1754
  const storeChatId = chatId; // sessionStore adds channel prefix internally
1657
1755
  const dbChatId = `api-${chatId}`; // historyDb uses full channel-chatId key
1658
- if (command === '/model') {
1659
- return { model: this.agentConfig.claude.model };
1660
- }
1661
- if (command === '/stop') {
1662
- const session = this.sessions.get(sessionId);
1663
- const stopped = session ? session.interrupt() : false;
1664
- return { stopped };
1665
- }
1666
- if (command === '/restart') {
1667
- this.restartProcess(sessionId).catch(() => { });
1668
- return { restarting: true };
1669
- }
1670
- if (command === '/session') {
1671
- const index = await this.sessionStore.listSessions(agentId, storeChatId, 'api').catch(() => null);
1672
- const meta = index?.sessions.find((s) => s.id === sessionId);
1673
- const effectiveModel = this.agentConfig.claude.model;
1674
- if (!meta)
1675
- return { sessionId, sessionName: null, messageCount: 0, archivedCount: 0, contextUsedPct: 0, model: effectiveModel };
1676
- const availableModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
1677
- const modelConfig = availableModels.find((m) => m.id === effectiveModel);
1678
- const contextWindow = modelConfig?.contextWindow ?? 200000;
1679
- const contextUsedPct = Math.round(((meta.lastInputTokens ?? 0) / contextWindow) * 100);
1680
- return {
1681
- sessionId,
1682
- sessionName: meta.name,
1683
- messageCount: meta.messageCount,
1684
- archivedCount: meta.archivedCount ?? 0,
1685
- contextUsedPct,
1686
- model: effectiveModel,
1687
- };
1756
+ const skipPersist = opts?.skipPersist ?? false;
1757
+ // Dispatch on the first token so commands with arguments (e.g. "/model sonnet",
1758
+ // "/stop now") resolve to their base command instead of falling through to the
1759
+ // unknown-command branch. The router gate is prefix-based, so these already pass.
1760
+ const cmd = command.trim().split(/\s+/)[0] ?? '';
1761
+ // Validate BEFORE persisting anything. Reuse the same api-channel gate as the router
1762
+ // so an unknown command throws with nothing written — no orphan user row is left, and
1763
+ // the router surfaces the error/500 exactly as before. After token dispatch + the
1764
+ // /sessions branch below, this is only reachable on a direct caller passing garbage.
1765
+ if (!AgentRunner.isApiBuiltinCommand(cmd)) {
1766
+ throw new Error(`Unknown command: ${cmd}`);
1767
+ }
1768
+ // Register session in the api-{chatId} index on first use (same as sendApiMessageStream)
1769
+ await this.sessionStore.ensureApiSession(agentId, storeChatId, sessionId).catch((err) => {
1770
+ this.logger.warn('Failed to register API session in index for command', { agentId, chatId, sessionId, error: err.message });
1771
+ });
1772
+ // Persist a command message to the permanent history DB ONLY — not the model's session
1773
+ // context (sessionStore). historyDb is what the web history endpoint reads, so this is
1774
+ // all the display + spinner fix needs. Keeping commands out of the JSONL context means
1775
+ // /model replies aren't replayed into the model, /compact counts stay accurate, /clear
1776
+ // leaves no dangling turn, and the api channel matches telegram (which persists commands
1777
+ // nowhere).
1778
+ //
1779
+ // skipPersist (wire param store_user_message=false) suppresses BOTH the user command and
1780
+ // the assistant reply: programmatic REST callers leave no trace in chat history at all.
1781
+ const persist = (role, content) => {
1782
+ if (skipPersist || !content)
1783
+ return;
1784
+ this.historyDb.insertMessage({ chatId: dbChatId, sessionId, source: 'api', role, content, ts: Date.now() });
1785
+ };
1786
+ // Persist the full user command before executing so it appears in history.
1787
+ // Skip for /clear — clearSession() below wipes the table anyway; only the response survives.
1788
+ if (cmd !== '/clear') {
1789
+ persist('user', command);
1688
1790
  }
1689
- if (command === '/clear') {
1690
- const ch = 'api';
1691
- await this.sessionStore.clearTelegramSessionHistory(agentId, storeChatId, sessionId, ch);
1692
- await this.sessionStore.updateSessionMeta(agentId, storeChatId, sessionId, {
1693
- totalTokensUsed: 0,
1694
- lastInputTokens: 0,
1695
- archivedCount: 0,
1696
- loadedAtSpawn: undefined,
1697
- messageCountAtSpawn: undefined,
1698
- }, ch);
1699
- const mediaPaths = this.historyDb.clearSession(dbChatId, sessionId);
1700
- media_store_1.MediaStore.deleteMediaFiles(this.agentsBaseDir, agentId, mediaPaths);
1701
- this.restartProcess(sessionId).catch(() => { });
1702
- return { success: true };
1703
- }
1704
- if (command === '/compact') {
1705
- const ch = 'api';
1706
- const compactEffectiveModel = this.agentConfig.claude.model;
1707
- const availableModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
1708
- const modelConfig = availableModels.find((m) => m.id === compactEffectiveModel);
1709
- const contextWindow = modelConfig?.contextWindow ?? 200000;
1710
- const compactor = new compactor_1.SessionCompactor(this.sessionStore);
1711
- const result = await compactor.compact(agentId, storeChatId, sessionId, compactEffectiveModel, contextWindow, ch);
1712
- await this.sessionStore.updateSessionMeta(agentId, storeChatId, sessionId, {
1713
- loadedAtSpawn: undefined,
1714
- archivedCount: undefined,
1715
- messageCountAtSpawn: undefined,
1716
- }, ch);
1717
- await this.restartProcess(sessionId);
1718
- return { success: true, keptMessages: result.afterMessages, archivedMessages: result.beforeMessages - result.afterMessages };
1791
+ let result;
1792
+ let responseText;
1793
+ try {
1794
+ if (cmd === '/model') {
1795
+ const model = this.agentConfig.claude.model;
1796
+ const hasArg = command.trim().includes(' ');
1797
+ result = { model };
1798
+ responseText = hasArg
1799
+ ? `Current model: ${model}\n(To switch models use the model picker or the /api/v1/agents/:id/model endpoint — argument ignored.)`
1800
+ : `Current model: ${model}`;
1801
+ }
1802
+ else if (cmd === '/stop') {
1803
+ const session = this.sessions.get(sessionId);
1804
+ const stopped = session ? session.interrupt() : false;
1805
+ result = { stopped };
1806
+ responseText = stopped ? 'Session interrupted.' : 'No active session to stop.';
1807
+ }
1808
+ else if (cmd === '/restart') {
1809
+ this.restartProcess(sessionId).catch(() => { });
1810
+ result = { restarting: true };
1811
+ responseText = 'Session is restarting.';
1812
+ }
1813
+ else if (cmd === '/session') {
1814
+ const index = await this.sessionStore.listSessions(agentId, storeChatId, 'api').catch(() => null);
1815
+ const meta = index?.sessions.find((s) => s.id === sessionId);
1816
+ const effectiveModel = this.agentConfig.claude.model;
1817
+ // The api append path doesn't maintain the index messageCount, so count the flat store
1818
+ // (the real conversation) directly rather than trusting meta.messageCount.
1819
+ const messageCount = (await this.sessionStore.loadSession(agentId, sessionId).catch(() => [])).length;
1820
+ if (!meta) {
1821
+ result = { sessionId, sessionName: null, messageCount, archivedCount: 0, contextUsedPct: 0, model: effectiveModel };
1822
+ responseText = `Session: (unnamed)\nMessages: ${messageCount}\nContext used: 0%\nModel: ${effectiveModel}`;
1823
+ }
1824
+ else {
1825
+ const availableModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
1826
+ const modelConfig = availableModels.find((m) => m.id === effectiveModel);
1827
+ const contextWindow = modelConfig?.contextWindow ?? 200000;
1828
+ const contextUsedPct = Math.round(((meta.lastInputTokens ?? 0) / contextWindow) * 100);
1829
+ result = { sessionId, sessionName: meta.name, messageCount, archivedCount: meta.archivedCount ?? 0, contextUsedPct, model: effectiveModel };
1830
+ responseText = [
1831
+ `Session: ${meta.name ?? '(unnamed)'}`,
1832
+ `Messages: ${messageCount}${(meta.archivedCount ?? 0) > 0 ? ` (${meta.archivedCount} archived)` : ''}`,
1833
+ `Context used: ${contextUsedPct}%`,
1834
+ `Model: ${effectiveModel}`,
1835
+ ].join('\n');
1836
+ }
1837
+ }
1838
+ else if (cmd === '/sessions') {
1839
+ // Advertised for the api channel (BUILTIN_COMMANDS), so handle it here — mirrors the
1840
+ // telegram /sessions list. Marks the session this command runs in as (current).
1841
+ const index = await this.sessionStore.listSessions(agentId, storeChatId, 'api').catch(() => null);
1842
+ const list = index?.sessions ?? [];
1843
+ // Count each session's flat store directly — the api append path doesn't keep the
1844
+ // index messageCount in sync.
1845
+ const withCounts = await Promise.all(list.map(async (s) => ({
1846
+ id: s.id,
1847
+ name: s.name,
1848
+ messageCount: (await this.sessionStore.loadSession(agentId, s.id).catch(() => [])).length,
1849
+ current: s.id === sessionId,
1850
+ })));
1851
+ result = { sessions: withCounts, count: withCounts.length };
1852
+ if (!withCounts.length) {
1853
+ responseText = 'No sessions.';
1854
+ }
1855
+ else {
1856
+ const lines = withCounts.map((s) => {
1857
+ const n = s.messageCount;
1858
+ const marker = s.current ? ' (current)' : '';
1859
+ return `• ${s.name ?? '(unnamed)'} — ${n} message${n === 1 ? '' : 's'}${marker}`;
1860
+ });
1861
+ responseText = `Sessions (${withCounts.length}):\n${lines.join('\n')}`;
1862
+ }
1863
+ }
1864
+ else if (cmd === '/clear') {
1865
+ const ch = 'api';
1866
+ await this.sessionStore.clearTelegramSessionHistory(agentId, storeChatId, sessionId, ch);
1867
+ await this.sessionStore.updateSessionMeta(agentId, storeChatId, sessionId, {
1868
+ totalTokensUsed: 0,
1869
+ lastInputTokens: 0,
1870
+ archivedCount: 0,
1871
+ loadedAtSpawn: undefined,
1872
+ messageCountAtSpawn: undefined,
1873
+ }, ch);
1874
+ const mediaPaths = this.historyDb.clearSession(dbChatId, sessionId);
1875
+ media_store_1.MediaStore.deleteMediaFiles(this.agentsBaseDir, agentId, mediaPaths);
1876
+ this.restartProcess(sessionId).catch(() => { });
1877
+ result = { success: true };
1878
+ responseText = 'Session cleared.';
1879
+ }
1880
+ else if (cmd === '/compact') {
1881
+ const ch = 'api';
1882
+ const compactEffectiveModel = this.agentConfig.claude.model;
1883
+ const availableModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
1884
+ const modelConfig = availableModels.find((m) => m.id === compactEffectiveModel);
1885
+ const contextWindow = modelConfig?.contextWindow ?? 200000;
1886
+ const compactor = new compactor_1.SessionCompactor(this.sessionStore);
1887
+ const compactResult = await compactor.compact(agentId, storeChatId, sessionId, compactEffectiveModel, contextWindow, ch);
1888
+ await this.sessionStore.updateSessionMeta(agentId, storeChatId, sessionId, {
1889
+ loadedAtSpawn: undefined,
1890
+ archivedCount: undefined,
1891
+ messageCountAtSpawn: undefined,
1892
+ }, ch);
1893
+ await this.restartProcess(sessionId);
1894
+ result = { success: true, keptMessages: compactResult.afterMessages, archivedMessages: compactResult.beforeMessages - compactResult.afterMessages };
1895
+ responseText = `Session compacted. Kept ${compactResult.afterMessages} messages, archived ${compactResult.beforeMessages - compactResult.afterMessages}.`;
1896
+ }
1897
+ else {
1898
+ // Passed the gate but has no dispatch branch — BUILTIN_COMMANDS drifted from this
1899
+ // table. Throw so the failure surfaces (and ends history with an assistant turn via
1900
+ // the catch below) rather than returning an empty response.
1901
+ throw new Error(`Unhandled command: ${cmd}`);
1902
+ }
1903
+ }
1904
+ catch (err) {
1905
+ // Ensure history always ends with an assistant turn, even when execution fails midway
1906
+ // (e.g. /compact throws after the user row is persisted). Otherwise the trailing user
1907
+ // row re-triggers the web's stuck-spinner condition on reload. Rethrow so the router
1908
+ // still emits its SSE error event / REST 500.
1909
+ const errMsg = err instanceof Error ? err.message : String(err);
1910
+ persist('assistant', `Command failed: ${errMsg}`);
1911
+ throw err;
1719
1912
  }
1720
- throw new Error(`Unknown command: ${command}`);
1913
+ // Persist the human-readable response as an assistant turn so the conversation ends with
1914
+ // an assistant message (the web's computeIsPendingResponse needs last.role !== 'user').
1915
+ persist('assistant', responseText);
1916
+ return { result, responseText };
1721
1917
  }
1722
1918
  async setModel(newModel) {
1723
1919
  // Allow any model string through — BYOK/third-party models (openrouter/* etc.)