claudity 1.1.0 → 1.2.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.
package/install.sh CHANGED
@@ -326,8 +326,13 @@ step "setting up auto-start"
326
326
  PLIST_NAME="ai.claudity.server"
327
327
  PLIST_PATH="$HOME/Library/LaunchAgents/${PLIST_NAME}.plist"
328
328
  NODE_PATH="$(which node)"
329
+ CLAUDE_PATH="$(which claude 2>/dev/null || echo "")"
329
330
  mkdir -p "$INSTALL_DIR/data"
330
331
 
332
+ PLIST_PATH_DIRS="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
333
+ [ -n "$NODE_PATH" ] && PLIST_PATH_DIRS="$(dirname "$NODE_PATH"):${PLIST_PATH_DIRS}"
334
+ [ -n "$CLAUDE_PATH" ] && PLIST_PATH_DIRS="$(dirname "$CLAUDE_PATH"):${PLIST_PATH_DIRS}"
335
+
331
336
  launchctl bootout "gui/$(id -u)/${PLIST_NAME}" 2>/dev/null || true
332
337
 
333
338
  cat > "$PLIST_PATH" <<PLIST
@@ -355,7 +360,7 @@ cat > "$PLIST_PATH" <<PLIST
355
360
  <key>EnvironmentVariables</key>
356
361
  <dict>
357
362
  <key>PATH</key>
358
- <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
363
+ <string>${PLIST_PATH_DIRS}</string>
359
364
  </dict>
360
365
  </dict>
361
366
  </plist>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudity",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "evolving agents that live where you chat ★ powered by claude code",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -765,13 +765,6 @@ div[aria-label="messages"] > div[data-activity] [data-status] {
765
765
  font-size: 11px;
766
766
  }
767
767
 
768
- div[aria-label="messages"] > div[data-activity] [data-summary] {
769
- color: var(--accent);
770
- font-size: 11px;
771
- margin-top: 4px;
772
- opacity: 0.7;
773
- }
774
-
775
768
  div[aria-label="messages"] > div[data-activity] [data-stop] {
776
769
  background: transparent;
777
770
  border: 1px solid var(--bg-hover);
@@ -790,6 +783,12 @@ div[aria-label="messages"] > div[data-activity] [data-stop]:hover {
790
783
  color: var(--danger);
791
784
  }
792
785
 
786
+ div[aria-label="messages"] > div[data-activity] [data-watchdog-warn] {
787
+ color: #f59e0b;
788
+ font-size: 11px;
789
+ margin: 0.3rem 0 0;
790
+ }
791
+
793
792
  div[aria-label="messages"] > div[data-role="system"] {
794
793
  color: var(--muted);
795
794
  font-size: 11px;
@@ -797,6 +796,69 @@ div[aria-label="messages"] > div[data-role="system"] {
797
796
  font-style: italic;
798
797
  }
799
798
 
799
+ [data-usage-bar] {
800
+ display: flex;
801
+ gap: 1.5rem;
802
+ padding: 0.5rem 1.5rem;
803
+ font-size: 10px;
804
+ color: var(--muted);
805
+ font-variant-numeric: tabular-nums;
806
+ flex-shrink: 0;
807
+ border-top: 1px solid var(--bg-raised);
808
+ }
809
+
810
+ [data-usage-bar][hidden] {
811
+ display: none;
812
+ }
813
+
814
+ [data-usage-bar] > div {
815
+ flex: 1;
816
+ min-width: 0;
817
+ }
818
+
819
+ [data-usage-bar] [data-quota-header] {
820
+ display: flex;
821
+ justify-content: space-between;
822
+ margin-bottom: 3px;
823
+ }
824
+
825
+ [data-usage-bar] [data-quota-header] span:first-child {
826
+ color: var(--white);
827
+ font-weight: 600;
828
+ }
829
+
830
+ [data-usage-bar] [data-quota-pct] {
831
+ color: var(--white);
832
+ }
833
+
834
+ [data-usage-bar] [data-quota-track] {
835
+ height: 6px;
836
+ background: var(--bg-raised);
837
+ border-radius: 3px;
838
+ overflow: hidden;
839
+ }
840
+
841
+ [data-usage-bar] [data-quota-fill] {
842
+ height: 100%;
843
+ border-radius: 3px;
844
+ background: var(--accent);
845
+ transition: width 0.4s ease, background 0.3s;
846
+ width: 0%;
847
+ }
848
+
849
+ [data-usage-bar] [data-quota-fill][data-warn] {
850
+ background: var(--amber);
851
+ }
852
+
853
+ [data-usage-bar] [data-quota-fill][data-danger] {
854
+ background: var(--danger);
855
+ }
856
+
857
+ [data-usage-bar] [data-quota-reset] {
858
+ margin-top: 2px;
859
+ font-size: 9px;
860
+ }
861
+
800
862
  form[aria-label="input"] {
801
863
  display: flex;
802
864
  gap: 0.5rem;
@@ -1493,6 +1555,11 @@ dialog[aria-label="connection"] footer button[data-disconnect]:hover {
1493
1555
  max-width: 95%;
1494
1556
  }
1495
1557
 
1558
+ [data-usage-bar] {
1559
+ padding: 0.5rem 0.75rem;
1560
+ gap: 0.75rem;
1561
+ }
1562
+
1496
1563
  form[aria-label="input"] {
1497
1564
  padding: 0.5rem 0.75rem 0.75rem;
1498
1565
  }
package/public/index.html CHANGED
@@ -8,7 +8,7 @@
8
8
  <meta name="theme-color" content="#000000">
9
9
  <meta name="color-scheme" content="dark">
10
10
  <link rel="icon" href="/favicon.svg" type="image/svg+xml">
11
- <link rel="stylesheet" href="css/style.css?cb=12">
11
+ <link rel="stylesheet" href="css/style.css?cb=20">
12
12
  </head>
13
13
  <body>
14
14
  <button type="button" aria-label="menu" aria-expanded="false" aria-controls="agent-nav" title="menu" data-action="toggle-nav">
@@ -114,6 +114,24 @@
114
114
 
115
115
  <div aria-label="messages" role="log" aria-live="polite"></div>
116
116
 
117
+ <div data-usage-bar hidden>
118
+ <div data-quota-session>
119
+ <div data-quota-header><span>session</span><span data-quota-pct>0%</span></div>
120
+ <div data-quota-track><div data-quota-fill></div></div>
121
+ <div data-quota-reset></div>
122
+ </div>
123
+ <div data-quota-weekly>
124
+ <div data-quota-header><span>weekly</span><span data-quota-pct>0%</span></div>
125
+ <div data-quota-track><div data-quota-fill></div></div>
126
+ <div data-quota-reset></div>
127
+ </div>
128
+ <div data-quota-overage>
129
+ <div data-quota-header><span>extra usage</span><span data-quota-pct>0%</span></div>
130
+ <div data-quota-track><div data-quota-fill></div></div>
131
+ <div data-quota-reset></div>
132
+ </div>
133
+ </div>
134
+
117
135
  <form aria-label="input">
118
136
  <textarea placeholder="say something..." rows="1" aria-label="message"></textarea>
119
137
  <button type="submit" aria-label="send message" title="send message">
@@ -257,6 +275,6 @@
257
275
  </footer>
258
276
  </dialog>
259
277
 
260
- <script src="js/app.js?cb=12"></script>
278
+ <script src="js/app.js?cb=20"></script>
261
279
  </body>
262
280
  </html>
package/public/js/app.js CHANGED
@@ -2,6 +2,7 @@ let agents = [];
2
2
  let activeAgent = null;
3
3
  let eventSource = null;
4
4
  let connectionsPoller = null;
5
+ let cachedQuota = null;
5
6
 
6
7
  async function api(method, path, body) {
7
8
  const opts = { method, headers: { 'content-type': 'application/json' } };
@@ -34,6 +35,7 @@ const chatInput = $('form[aria-label="input"] textarea');
34
35
  const chatSubmit = $('form[aria-label="input"] button[type="submit"]');
35
36
  const connectionsBtn = $('button[data-action="connections"]');
36
37
  const platformsDiv = $('[data-platforms]');
38
+ const usageBar = $('[data-usage-bar]');
37
39
 
38
40
  const iconRobot = '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M352 0c0-17.7-14.3-32-32-32S288-17.7 288 0l0 64-96 0c-53 0-96 43-96 96l0 224c0 53 43 96 96 96l256 0c53 0 96-43 96-96l0-224c0-53-43-96-96-96l-96 0 0-64zM160 368c0-13.3 10.7-24 24-24l32 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-32 0c-13.3 0-24-10.7-24-24zm120 0c0-13.3 10.7-24 24-24l32 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-32 0c-13.3 0-24-10.7-24-24zm120 0c0-13.3 10.7-24 24-24l32 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-32 0c-13.3 0-24-10.7-24-24zM224 176a48 48 0 1 1 0 96 48 48 0 1 1 0-96zm144 48a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zM64 224c0-17.7-14.3-32-32-32S0 206.3 0 224l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96zm544-32c-17.7 0-32 14.3-32 32l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32z"/></svg>';
39
41
 
@@ -144,6 +146,44 @@ function esc(str) {
144
146
  return el.innerHTML;
145
147
  }
146
148
 
149
+ function formatReset(ts) {
150
+ if (!ts) return '';
151
+ const d = new Date(ts * 1000);
152
+ const now = new Date();
153
+ const sameDay = d.toDateString() === now.toDateString();
154
+ const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
155
+ if (sameDay) return `resets ${time}`;
156
+ const date = d.toLocaleDateString([], { month: 'short', day: 'numeric' });
157
+ return `resets ${date} ${time}`;
158
+ }
159
+
160
+ function updateQuotaBar(container, util, reset) {
161
+ const pct = Math.floor(util * 100);
162
+ const fill = container.querySelector('[data-quota-fill]');
163
+ container.querySelector('[data-quota-pct]').textContent = pct + '%';
164
+ fill.style.width = pct + '%';
165
+ delete fill.dataset.warn;
166
+ delete fill.dataset.danger;
167
+ if (pct >= 90) fill.dataset.danger = '';
168
+ else if (pct >= 70) fill.dataset.warn = '';
169
+ container.querySelector('[data-quota-reset]').textContent = formatReset(reset);
170
+ }
171
+
172
+ function applyQuota(data) {
173
+ if (!data || !data.available) return;
174
+ if (data.session) updateQuotaBar($('[data-quota-session]', usageBar), data.session.utilization, data.session.reset);
175
+ if (data.weekly) updateQuotaBar($('[data-quota-weekly]', usageBar), data.weekly.utilization, data.weekly.reset);
176
+ if (data.overage) updateQuotaBar($('[data-quota-overage]', usageBar), data.overage.utilization, data.overage.reset);
177
+ }
178
+
179
+ async function fetchQuota() {
180
+ try {
181
+ const data = await api('GET', '/api/quota');
182
+ cachedQuota = data;
183
+ applyQuota(data);
184
+ } catch {}
185
+ }
186
+
147
187
  function renderMarkdown(text) {
148
188
  let html = esc(text);
149
189
  html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
@@ -248,6 +288,9 @@ async function selectAgent(id) {
248
288
  chatInput.value = '';
249
289
  chatSubmit.disabled = false;
250
290
 
291
+ usageBar.hidden = false;
292
+ if (cachedQuota) applyQuota(cachedQuota);
293
+ fetchQuota();
251
294
 
252
295
  try {
253
296
  const messages = await api('GET', `/api/agents/${id}/messages`);
@@ -400,7 +443,7 @@ function stopActivityTimer() {
400
443
  }
401
444
 
402
445
  function updateToolStatus(toolName) {
403
- if (toolName !== currentToolName) { if (currentToolName !== null) toolStartTime = Date.now(); }
446
+ if (toolName !== currentToolName) toolStartTime = Date.now();
404
447
  currentToolName = toolName;
405
448
  const activity = showActivity();
406
449
  if (!activity) return;
@@ -421,20 +464,7 @@ function updateToolStatus(toolName) {
421
464
  scrollToBottom();
422
465
  }
423
466
 
424
- function updateStatusSummary(summary) {
425
- const activity = showActivity();
426
- if (!activity) return;
427
- let el = activity.querySelector('[data-summary]');
428
- if (!el) {
429
- el = document.createElement('p');
430
- el.dataset.summary = '';
431
- activity.appendChild(el);
432
- }
433
- el.textContent = summary;
434
- const dots = activity.querySelector('[data-dots]');
435
- if (dots) dots.remove();
436
- scrollToBottom();
437
- }
467
+
438
468
 
439
469
  function connectSSE(agentId) {
440
470
  if (eventSource) eventSource.close();
@@ -461,15 +491,6 @@ function connectSSE(agentId) {
461
491
  } catch {}
462
492
  });
463
493
 
464
- eventSource.addEventListener('ack_message', (e) => {
465
- try {
466
- clearActivity();
467
- const data = JSON.parse(e.data);
468
- appendMessage('assistant', data.content);
469
- scrollToBottom();
470
- } catch {}
471
- });
472
-
473
494
  eventSource.addEventListener('intermediate', (e) => {
474
495
  try {
475
496
  const data = JSON.parse(e.data);
@@ -486,13 +507,6 @@ function connectSSE(agentId) {
486
507
  } catch {}
487
508
  });
488
509
 
489
- eventSource.addEventListener('status_update', (e) => {
490
- try {
491
- const data = JSON.parse(e.data);
492
- updateStatusSummary(data.summary);
493
- } catch {}
494
- });
495
-
496
510
  eventSource.addEventListener('assistant_message', (e) => {
497
511
  try {
498
512
  clearActivity();
@@ -503,6 +517,10 @@ function connectSSE(agentId) {
503
517
  } catch {}
504
518
  });
505
519
 
520
+ eventSource.addEventListener('usage', () => {
521
+ fetchQuota();
522
+ });
523
+
506
524
  eventSource.addEventListener('user_message', (e) => {
507
525
  try {
508
526
  const data = JSON.parse(e.data);
@@ -1335,6 +1353,7 @@ async function init() {
1335
1353
  agents = await api('GET', '/api/agents');
1336
1354
  renderAgents();
1337
1355
  showSection('empty');
1356
+ fetchQuota();
1338
1357
  } catch {
1339
1358
  showSection('setup');
1340
1359
  }
package/src/db.js CHANGED
@@ -107,6 +107,23 @@ db.exec(`
107
107
  )
108
108
  `);
109
109
 
110
+ db.exec(`
111
+ create table if not exists token_usage (
112
+ id text primary key,
113
+ agent_id text not null,
114
+ input_tokens integer not null default 0,
115
+ output_tokens integer not null default 0,
116
+ cache_read_tokens integer not null default 0,
117
+ cache_write_tokens integer not null default 0,
118
+ created_at datetime default current_timestamp,
119
+ foreign key (agent_id) references agents(id) on delete cascade
120
+ )
121
+ `);
122
+
123
+ try {
124
+ db.exec('create index if not exists idx_token_usage_agent on token_usage(agent_id)');
125
+ } catch {}
126
+
110
127
  const stmts = {
111
128
  getConfig: db.prepare('select value from config where key = ?'),
112
129
  setConfig: db.prepare('insert into config (key, value, updated_at) values (?, ?, current_timestamp) on conflict(key) do update set value = excluded.value, updated_at = current_timestamp'),
@@ -157,6 +174,9 @@ const stmts = {
157
174
  getSession: db.prepare('select * from sessions where agent_id = ?'),
158
175
  upsertSession: db.prepare('insert into sessions (agent_id, session_id, prompt_hash, updated_at) values (?, ?, ?, current_timestamp) on conflict(agent_id) do update set session_id = excluded.session_id, prompt_hash = excluded.prompt_hash, updated_at = current_timestamp'),
159
176
  deleteSession: db.prepare('delete from sessions where agent_id = ?'),
177
+
178
+ createUsage: db.prepare('insert into token_usage (id, agent_id, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens) values (?, ?, ?, ?, ?, ?)'),
179
+ agentUsageTotals: db.prepare('select coalesce(sum(input_tokens), 0) as input_tokens, coalesce(sum(output_tokens), 0) as output_tokens, coalesce(sum(cache_read_tokens), 0) as cache_read_tokens, coalesce(sum(cache_write_tokens), 0) as cache_write_tokens from token_usage where agent_id = ?'),
160
180
  };
161
181
 
162
182
  module.exports = { db, stmts };
package/src/routes/api.js CHANGED
@@ -3,6 +3,7 @@ const { v4: uuid } = require('uuid');
3
3
  const { stmts } = require('../db');
4
4
  const auth = require('../services/auth');
5
5
  const chat = require('../services/chat');
6
+ const claude = require('../services/claude');
6
7
  const workspace = require('../services/workspace');
7
8
  const heartbeat = require('../services/heartbeat');
8
9
 
@@ -121,6 +122,23 @@ router.delete('/agents/:id/messages', (req, res) => {
121
122
  res.json({ cleared: true });
122
123
  });
123
124
 
125
+ router.get('/agents/:id/usage', (req, res) => {
126
+ const agent = stmts.getAgent.get(req.params.id);
127
+ if (!agent) return res.status(404).json({ error: 'agent not found' });
128
+ const totals = stmts.agentUsageTotals.get(req.params.id);
129
+ res.json(totals);
130
+ });
131
+
132
+ router.get('/quota', async (req, res) => {
133
+ try {
134
+ const quota = await claude.probeQuota();
135
+ if (!quota) return res.json({ available: false });
136
+ res.json({ available: true, ...quota });
137
+ } catch {
138
+ res.json({ available: false });
139
+ }
140
+ });
141
+
124
142
  router.post('/agents/:id/stop', (req, res) => {
125
143
  const stopped = chat.stopAgent(req.params.id);
126
144
  res.json({ stopped });
@@ -156,13 +174,13 @@ router.get('/agents/:id/stream', (req, res) => {
156
174
  const elapsed = activity ? Math.floor((Date.now() - activity.startTime) / 1000) : 0;
157
175
  res.write(`event: typing\ndata: ${JSON.stringify({ active: true, elapsed })}\n\n`);
158
176
  if (activity) {
177
+ if (activity.intermediateText) {
178
+ res.write(`event: intermediate\ndata: ${JSON.stringify({ content: activity.intermediateText, elapsed })}\n\n`);
179
+ }
159
180
  if (activity.tool) {
160
181
  const toolElapsed = activity.toolStartTime ? Math.floor((Date.now() - activity.toolStartTime) / 1000) : 0;
161
182
  res.write(`event: tool_call\ndata: ${JSON.stringify({ name: activity.tool, elapsed, toolElapsed })}\n\n`);
162
183
  }
163
- if (activity.summary) {
164
- res.write(`event: status_update\ndata: ${JSON.stringify({ summary: activity.summary, elapsed, tool: activity.tool || 'thinking' })}\n\n`);
165
- }
166
184
  }
167
185
  }
168
186
 
@@ -4,6 +4,21 @@ const { stmts } = require('../db');
4
4
  let cachedCredentials = null;
5
5
 
6
6
  function readKeychain() {
7
+ const accounts = [process.env.USER, 'Claude Code'];
8
+ for (const acct of accounts) {
9
+ try {
10
+ const raw = execSync(
11
+ `security find-generic-password -s "Claude Code-credentials" -a "${acct}" -w`,
12
+ { encoding: 'utf8', timeout: 5000 }
13
+ ).trim();
14
+ const parsed = JSON.parse(raw);
15
+ const creds = parsed.claudeAiOauth || parsed;
16
+ if (creds.accessToken && creds.accessToken.startsWith('sk-ant-')) {
17
+ cachedCredentials = creds;
18
+ return creds;
19
+ }
20
+ } catch {}
21
+ }
7
22
  try {
8
23
  const raw = execSync(
9
24
  'security find-generic-password -s "Claude Code-credentials" -w',
@@ -64,10 +79,18 @@ function getAuthStatus() {
64
79
  }
65
80
 
66
81
  function getHeaders() {
67
- const token = getAccessToken();
68
- if (!token) return null;
82
+ const apiKey = getApiKey();
83
+ if (apiKey) {
84
+ return {
85
+ 'x-api-key': apiKey,
86
+ 'content-type': 'application/json',
87
+ 'anthropic-version': '2023-06-01'
88
+ };
89
+ }
90
+ const creds = cachedCredentials || readKeychain();
91
+ if (!creds || !creds.accessToken) return null;
69
92
  return {
70
- 'x-api-key': token,
93
+ 'authorization': `Bearer ${creds.accessToken}`,
71
94
  'content-type': 'application/json',
72
95
  'anthropic-version': '2023-06-01'
73
96
  };
@@ -100,7 +100,7 @@ your memories are automatically extracted from conversations and written to dail
100
100
 
101
101
  your workspace is at data/agents/${workspace.sanitizeName(agent.name)}/. you can read and write your own files using read_workspace and write_workspace. your soul, identity, memory, and heartbeat files are yours to evolve.
102
102
 
103
- when using tools, just use them naturally as part of the conversation - no need to announce plans or ask permission. when interacting with external platforms, read their documentation first to understand the api.
103
+ always tell the user what you're about to do before doing it. share your plan, intention, or approach in natural language first, then use tools to execute. don't ask permission - just say what you're doing and do it. when interacting with external platforms, read their documentation first to understand the api.
104
104
 
105
105
  if the user asks you to do something repeatedly or on a schedule, use the schedule_task tool to set it up. you will receive scheduled reminders as messages and should act on them autonomously.
106
106
 
@@ -137,7 +137,6 @@ async function handleMessage(agentId, userContent, options = {}) {
137
137
  if (!agent) throw new Error('agent not found');
138
138
 
139
139
  const isHeartbeat = !!options.heartbeat;
140
- const isScheduled = typeof userContent === 'string' && userContent.startsWith('[scheduled reminder]');
141
140
 
142
141
  if (!isHeartbeat) {
143
142
  const userMsgId = uuid();
@@ -147,29 +146,12 @@ async function handleMessage(agentId, userContent, options = {}) {
147
146
 
148
147
  if (!isHeartbeat) {
149
148
  processingAgents.add(agentId);
150
- agentActivity.set(agentId, { startTime: Date.now(), tool: null, toolStartTime: null, summary: null });
149
+ agentActivity.set(agentId, { startTime: Date.now(), tool: null, toolStartTime: null, intermediateText: null });
151
150
  emit(agentId, 'typing', { active: true });
152
151
  }
153
152
 
154
- let responseComplete = false;
155
153
  const startTime = Date.now();
156
154
  let lastToolName = null;
157
- let statusInterval = null;
158
-
159
- if (!isHeartbeat) {
160
- statusInterval = setInterval(async () => {
161
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
162
- const tool = lastToolName || 'thinking';
163
- try {
164
- const summary = await claude.generateStatus(tool, elapsed, agent.name);
165
- if (summary) {
166
- const state = agentActivity.get(agentId);
167
- if (state) state.summary = summary;
168
- emit(agentId, 'status_update', { elapsed, summary, tool });
169
- }
170
- } catch {}
171
- }, 60000);
172
- }
173
155
 
174
156
  const systemPrompt = buildSystemPrompt(agent);
175
157
  const toolDefs = tools.getAllToolDefinitions();
@@ -184,26 +166,37 @@ async function handleMessage(agentId, userContent, options = {}) {
184
166
 
185
167
  let allToolCalls = [];
186
168
  let intermediateTexts = [];
187
-
188
- const wantsAck = !isHeartbeat && !isScheduled && !isBootstrap;
189
- const ackPromise = wantsAck
190
- ? claude.generateQuickAck(userContent, agent.name).catch(() => null)
191
- : Promise.resolve(null);
169
+ const totalUsage = { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 };
192
170
 
193
171
  const onEvent = !isHeartbeat ? (event) => {
194
- if (event.type === 'assistant' && event.message && event.message.content) {
195
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
196
- for (const block of event.message.content) {
197
- if (block.type === 'tool_use') {
198
- lastToolName = block.name;
199
- const state = agentActivity.get(agentId);
200
- if (state) { state.tool = block.name; state.toolStartTime = Date.now(); }
201
- emit(agentId, 'tool_call', { name: block.name, input: block.input, elapsed });
172
+ if (event.type !== 'assistant' || !event.message?.content) return;
173
+
174
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
175
+
176
+ for (const block of event.message.content) {
177
+ if (block.type === 'tool_use') {
178
+ lastToolName = block.name;
179
+ const state = agentActivity.get(agentId);
180
+ if (state) { state.tool = block.name; state.toolStartTime = Date.now(); }
181
+ emit(agentId, 'tool_call', { name: block.name, input: block.input, elapsed });
182
+ } else if (block.type === 'text' && block.text?.trim()) {
183
+ const text = block.text.trim();
184
+ const state = agentActivity.get(agentId);
185
+ if (state) state.intermediateText = text;
186
+ emit(agentId, 'intermediate', { content: text, elapsed });
187
+ if (options.onAck) {
188
+ try { options.onAck(text); } catch {}
202
189
  }
203
190
  }
204
191
  }
205
192
  } : null;
206
193
 
194
+ const ackThinking = (text) => {
195
+ if (options.onAck && text) {
196
+ try { options.onAck(text); } catch {}
197
+ }
198
+ };
199
+
207
200
  const mainPromise = claude.sendMessage({
208
201
  system: systemPrompt,
209
202
  messages,
@@ -217,30 +210,12 @@ async function handleMessage(agentId, userContent, options = {}) {
217
210
  });
218
211
 
219
212
  try {
220
- let response;
221
-
222
- if (wantsAck) {
223
- const raceResult = await Promise.race([
224
- mainPromise.then(r => ({ type: 'main', result: r })),
225
- new Promise(resolve => setTimeout(() => resolve({ type: 'timeout' }), 8000))
226
- ]);
227
-
228
- if (raceResult.type === 'main') {
229
- response = raceResult.result;
230
- } else {
231
- const quickAck = await ackPromise;
232
- if (quickAck) {
233
- const ackMsgId = uuid();
234
- stmts.createMessage.run(ackMsgId, agentId, 'assistant', quickAck, null);
235
- emit(agentId, 'typing', { active: false });
236
- emit(agentId, 'ack_message', { content: quickAck });
237
- if (options.onAck) options.onAck(quickAck);
238
- emit(agentId, 'typing', { active: true });
239
- }
240
- response = await mainPromise;
241
- }
242
- } else {
243
- response = await mainPromise;
213
+ let response = await mainPromise;
214
+ if (response.usage) {
215
+ totalUsage.input_tokens += response.usage.input_tokens;
216
+ totalUsage.output_tokens += response.usage.output_tokens;
217
+ totalUsage.cache_read_tokens += response.usage.cache_read_tokens;
218
+ totalUsage.cache_write_tokens += response.usage.cache_write_tokens;
244
219
  }
245
220
 
246
221
  while (claude.hasToolUse(response)) {
@@ -250,7 +225,10 @@ async function handleMessage(agentId, userContent, options = {}) {
250
225
 
251
226
  if (thinkingText) {
252
227
  intermediateTexts.push(thinkingText);
228
+ const state = agentActivity.get(agentId);
229
+ if (state) state.intermediateText = thinkingText;
253
230
  emit(agentId, 'intermediate', { content: thinkingText });
231
+ ackThinking(thinkingText);
254
232
  }
255
233
 
256
234
  messages.push({ role: 'assistant', content: response.content });
@@ -298,6 +276,12 @@ async function handleMessage(agentId, userContent, options = {}) {
298
276
  noBuiltinTools: isBootstrap,
299
277
  onEvent
300
278
  });
279
+ if (response.usage) {
280
+ totalUsage.input_tokens += response.usage.input_tokens;
281
+ totalUsage.output_tokens += response.usage.output_tokens;
282
+ totalUsage.cache_read_tokens += response.usage.cache_read_tokens;
283
+ totalUsage.cache_write_tokens += response.usage.cache_write_tokens;
284
+ }
301
285
  }
302
286
 
303
287
  let rawText = claude.extractText(response);
@@ -334,11 +318,9 @@ async function handleMessage(agentId, userContent, options = {}) {
334
318
  const stripped = responseText.replace(/\s+/g, ' ').trim();
335
319
  const isOk = stripped.includes('HEARTBEAT_OK') && stripped.length <= 300;
336
320
  if (isOk) {
337
- responseComplete = true;
338
321
  return { id: null, content: responseText, suppressed: true };
339
322
  }
340
323
  stmts.createHeartbeatMessage.run(assistantMsgId, agentId, 'assistant', responseText, toolCallsJson);
341
- responseComplete = true;
342
324
  emit(agentId, 'heartbeat_alert', {
343
325
  id: assistantMsgId,
344
326
  content: responseText,
@@ -349,6 +331,13 @@ async function handleMessage(agentId, userContent, options = {}) {
349
331
 
350
332
  stmts.createMessage.run(assistantMsgId, agentId, 'assistant', responseText, toolCallsJson);
351
333
 
334
+ if (totalUsage.input_tokens > 0 || totalUsage.output_tokens > 0) {
335
+ try {
336
+ stmts.createUsage.run(uuid(), agentId, totalUsage.input_tokens, totalUsage.output_tokens, totalUsage.cache_read_tokens, totalUsage.cache_write_tokens);
337
+ } catch {}
338
+ emit(agentId, 'usage', totalUsage);
339
+ }
340
+
352
341
  if (isBootstrap && stmts.getAgent.get(agentId)?.bootstrapped !== 0) {
353
342
  emit(agentId, 'bootstrap_complete', {});
354
343
  }
@@ -359,8 +348,6 @@ async function handleMessage(agentId, userContent, options = {}) {
359
348
  });
360
349
  }
361
350
 
362
- if (statusInterval) clearInterval(statusInterval);
363
- responseComplete = true;
364
351
  processingAgents.delete(agentId);
365
352
  agentActivity.delete(agentId);
366
353
  emit(agentId, 'typing', { active: false });
@@ -373,8 +360,6 @@ async function handleMessage(agentId, userContent, options = {}) {
373
360
  return { id: assistantMsgId, content: responseText, tool_calls: allToolCalls.length ? allToolCalls : null };
374
361
 
375
362
  } catch (err) {
376
- if (statusInterval) clearInterval(statusInterval);
377
- responseComplete = true;
378
363
  processingAgents.delete(agentId);
379
364
  agentActivity.delete(agentId);
380
365
  if (!isHeartbeat) emit(agentId, 'typing', { active: false });
@@ -392,6 +377,7 @@ function enqueueMessage(agentId, content, options = {}) {
392
377
  messageQueues.delete(agentId);
393
378
  }
394
379
  });
380
+ tracked.catch(() => {});
395
381
  messageQueues.set(agentId, tracked);
396
382
  return chained;
397
383
  }
@@ -10,6 +10,28 @@ const API_URL = 'https://api.anthropic.com/v1/messages';
10
10
  const MODEL = 'claude-opus-4-6';
11
11
  const activeProcesses = new Map();
12
12
 
13
+ let supportsEffort = null;
14
+ function checkEffortSupport() {
15
+ if (supportsEffort !== null) return supportsEffort;
16
+ try {
17
+ const { execSync } = require('child_process');
18
+ const help = execSync('claude --help 2>&1', { encoding: 'utf8' });
19
+ supportsEffort = help.includes('--effort');
20
+ } catch {
21
+ supportsEffort = false;
22
+ }
23
+ return supportsEffort;
24
+ }
25
+
26
+ function applyEffort(args, extraEnv, effort) {
27
+ if (checkEffortSupport()) {
28
+ args.push('--effort', effort);
29
+ } else {
30
+ const tokens = { low: '0', medium: '16000', high: '31999' };
31
+ extraEnv.MAX_THINKING_TOKENS = tokens[effort] || '31999';
32
+ }
33
+ }
34
+
13
35
  function hashPrompt(str) {
14
36
  let h = 0;
15
37
  for (let i = 0; i < str.length; i++) {
@@ -143,9 +165,12 @@ function runCli(args, input, extraEnv = {}, agentId = null, onEvent = null) {
143
165
  }
144
166
  }
145
167
  });
146
- proc.stderr.on('data', d => stderr += d);
168
+ proc.stderr.on('data', d => {
169
+ stderr += d;
170
+ });
147
171
 
148
172
  proc.on('close', code => {
173
+ try { fs.rmSync(cwd, { recursive: true, force: true }); } catch {}
149
174
  if (done) return;
150
175
  done = true;
151
176
  if (agentId) activeProcesses.delete(agentId);
@@ -169,6 +194,7 @@ function runCli(args, input, extraEnv = {}, agentId = null, onEvent = null) {
169
194
  });
170
195
 
171
196
  proc.on('error', err => {
197
+ clearInterval(idleCheck);
172
198
  if (done) return;
173
199
  done = true;
174
200
  reject(err);
@@ -191,9 +217,11 @@ async function sendViaCli({ system, messages, tools, maxTokens, agentId, model =
191
217
  let output;
192
218
 
193
219
  if (canResume) {
194
- const args = ['-p', '--output-format', 'stream-json', '--verbose', '--effort', effort, '--dangerously-skip-permissions', '--setting-sources', '', '--resume', session.session_id];
220
+ const args = ['-p', '--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions', '--setting-sources', '', '--resume', session.session_id];
221
+ const extraEnv = {};
222
+ applyEffort(args, extraEnv, effort);
195
223
  try {
196
- output = await runCli(args, promptText, {}, agentId, onEvent);
224
+ output = await runCli(args, promptText, extraEnv, agentId, onEvent);
197
225
  return processCliOutput(output, tools);
198
226
  } catch (err) {
199
227
  if (err.message === 'aborted') throw err;
@@ -216,14 +244,16 @@ async function sendCliFresh({ sysPrompt, messages, promptText, tools, agentId, c
216
244
  if (context) fullPrompt += `previous conversation:\n${context}\n\n`;
217
245
  fullPrompt += promptText;
218
246
 
219
- const args = ['-p', '--output-format', 'stream-json', '--verbose', '--model', model, '--effort', effort, '--dangerously-skip-permissions', '--setting-sources', '', '--session-id', sessionId];
247
+ const args = ['-p', '--output-format', 'stream-json', '--verbose', '--model', model, '--dangerously-skip-permissions', '--setting-sources', '', '--session-id', sessionId];
248
+ const extraEnv = {};
249
+ applyEffort(args, extraEnv, effort);
220
250
  if (noBuiltinTools) args.push('--tools', '');
221
251
  if (sysPrompt) {
222
252
  args.push('--system-prompt', sysPrompt);
223
253
  }
224
254
 
225
255
  try {
226
- const output = await runCli(args, fullPrompt, {}, agentId, onEvent);
256
+ const output = await runCli(args, fullPrompt, extraEnv, agentId, onEvent);
227
257
 
228
258
  if (agentId) {
229
259
  stmts.upsertSession.run(agentId, sessionId, currentHash);
@@ -233,9 +263,11 @@ async function sendCliFresh({ sysPrompt, messages, promptText, tools, agentId, c
233
263
  } catch (err) {
234
264
  if (err.message === 'aborted') throw err;
235
265
  if (isContextOverflow(err) && context) {
236
- const retryArgs = ['-p', '--output-format', 'stream-json', '--verbose', '--model', model, '--effort', effort, '--dangerously-skip-permissions', '--setting-sources', '', '--session-id', randomUUID()];
266
+ const retryArgs = ['-p', '--output-format', 'stream-json', '--verbose', '--model', model, '--dangerously-skip-permissions', '--setting-sources', '', '--session-id', randomUUID()];
267
+ const retryEnv = {};
268
+ applyEffort(retryArgs, retryEnv, effort);
237
269
  if (sysPrompt) retryArgs.push('--system-prompt', sysPrompt);
238
- const output = await runCli(retryArgs, promptText, {}, agentId);
270
+ const output = await runCli(retryArgs, promptText, retryEnv, agentId);
239
271
  return processCliOutput(output, tools);
240
272
  }
241
273
  throw err;
@@ -246,6 +278,13 @@ function processCliOutput(output, tools) {
246
278
  const parsed = parseCliOutput(output);
247
279
  if (!parsed) throw new Error('claude cli returned empty response');
248
280
 
281
+ const usage = parsed.usage ? {
282
+ input_tokens: parsed.usage.input_tokens || 0,
283
+ output_tokens: parsed.usage.output_tokens || 0,
284
+ cache_read_tokens: parsed.usage.cache_read_input_tokens || 0,
285
+ cache_write_tokens: parsed.usage.cache_creation_input_tokens || 0
286
+ } : null;
287
+
249
288
  if (parsed.is_error && parsed.num_turns === 0) {
250
289
  throw new Error('cli session error');
251
290
  }
@@ -254,23 +293,32 @@ function processCliOutput(output, tools) {
254
293
  const text = typeof parsed.result === 'string' && parsed.result.length > 0
255
294
  ? parsed.result
256
295
  : '';
257
- return buildResponse(text, tools);
296
+ const resp = buildResponse(text, tools);
297
+ resp.usage = usage;
298
+ return resp;
258
299
  }
259
300
 
260
301
  if (parsed.result !== undefined && parsed.result !== null && parsed.result !== '') {
261
302
  const text = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result);
262
- return buildResponse(text, tools);
303
+ const resp = buildResponse(text, tools);
304
+ resp.usage = usage;
305
+ return resp;
263
306
  }
264
307
 
265
308
  if (parsed.content) {
309
+ parsed.usage = usage;
266
310
  return parsed;
267
311
  }
268
312
 
269
313
  if (parsed.type === 'result') {
270
- return buildResponse('', tools);
314
+ const resp = buildResponse('', tools);
315
+ resp.usage = usage;
316
+ return resp;
271
317
  }
272
318
 
273
- return buildResponse(output, tools);
319
+ const resp = buildResponse(output, tools);
320
+ resp.usage = usage;
321
+ return resp;
274
322
  }
275
323
 
276
324
  function parseCliOutput(output) {
@@ -358,19 +406,6 @@ function hasToolUse(response) {
358
406
  return response.stop_reason === 'tool_use';
359
407
  }
360
408
 
361
- async function generateQuickAck(userContent, agentName) {
362
- const args = ['-p', '--output-format', 'json', '--model', 'haiku', '--dangerously-skip-permissions', '--setting-sources', ''];
363
- const prompt = `you are ${agentName}. the user just said: "${userContent}"\n\nacknowledge their message in one casual sentence - show you understood what they want and you're about to start. don't answer or attempt the task, just the acknowledgment. no quotes.`;
364
- try {
365
- const output = await runCli(args, prompt);
366
- const parsed = parseCliOutput(output);
367
- if (parsed && typeof parsed.result === 'string' && parsed.result.length > 0) {
368
- return parsed.result.trim();
369
- }
370
- } catch {}
371
- return null;
372
- }
373
-
374
409
  function abort(agentId) {
375
410
  const proc = activeProcesses.get(agentId);
376
411
  if (proc) {
@@ -381,18 +416,58 @@ function abort(agentId) {
381
416
  return false;
382
417
  }
383
418
 
384
- async function generateStatus(toolName, elapsedSeconds, agentName) {
385
- const mins = Math.floor(elapsedSeconds / 60);
386
- const args = ['-p', '--output-format', 'json', '--model', 'haiku', '--dangerously-skip-permissions', '--setting-sources', ''];
387
- const prompt = `you are ${agentName}. you've been working for ${mins} minute${mins === 1 ? '' : 's'} and you're currently using ${toolName}. give a one-sentence first-person casual status update for your user. no quotes.`;
419
+ async function probeQuota() {
420
+ const status = auth.getAuthStatus();
421
+ if (!status.authenticated) return null;
422
+
423
+ const headers = auth.getHeaders();
424
+ if (!headers) return null;
425
+ if (headers.authorization) headers['anthropic-beta'] = 'oauth-2025-04-20';
426
+
388
427
  try {
389
- const output = await runCli(args, prompt);
390
- const parsed = parseCliOutput(output);
391
- if (parsed && typeof parsed.result === 'string' && parsed.result.length > 0) {
392
- return parsed.result.trim().replace(/^["']|["']$/g, '');
428
+ const res = await fetch(API_URL, {
429
+ method: 'POST',
430
+ headers,
431
+ body: JSON.stringify({
432
+ model: MODEL,
433
+ max_tokens: 1,
434
+ messages: [{ role: 'user', content: 'quota' }]
435
+ })
436
+ });
437
+
438
+ res.text().catch(() => {});
439
+ const quota = {};
440
+ const h = res.headers;
441
+
442
+ const session = h.get('anthropic-ratelimit-unified-5h-utilization');
443
+ if (session !== null) {
444
+ quota.session = {
445
+ utilization: parseFloat(session),
446
+ reset: parseInt(h.get('anthropic-ratelimit-unified-5h-reset') || '0', 10)
447
+ };
393
448
  }
394
- } catch {}
395
- return null;
449
+
450
+ const weekly = h.get('anthropic-ratelimit-unified-7d-utilization');
451
+ if (weekly !== null) {
452
+ quota.weekly = {
453
+ utilization: parseFloat(weekly),
454
+ reset: parseInt(h.get('anthropic-ratelimit-unified-7d-reset') || '0', 10)
455
+ };
456
+ }
457
+
458
+ const overageUtil = h.get('anthropic-ratelimit-unified-overage-utilization');
459
+ if (overageUtil !== null) {
460
+ quota.overage = {
461
+ utilization: parseFloat(overageUtil),
462
+ reset: parseInt(h.get('anthropic-ratelimit-unified-overage-reset') || '0', 10)
463
+ };
464
+ }
465
+
466
+ if (!quota.session && !quota.weekly) return null;
467
+ return quota;
468
+ } catch {
469
+ return null;
470
+ }
396
471
  }
397
472
 
398
- module.exports = { sendMessage, extractText, extractToolUse, hasToolUse, generateQuickAck, generateStatus, abort };
473
+ module.exports = { sendMessage, extractText, extractToolUse, hasToolUse, abort, probeQuota };
@@ -1,6 +1,7 @@
1
1
  const { Client, GatewayIntentBits, Partials } = require('discord.js');
2
2
 
3
3
  const MAX_RESPONSE_LENGTH = 1900;
4
+ const EDIT_THROTTLE = 5000;
4
5
 
5
6
  let client = null;
6
7
  let typingTimer = null;
@@ -17,6 +18,10 @@ function parseMessage(text, botId) {
17
18
  return { agent: match[1].toLowerCase(), command: match[2].trim() };
18
19
  }
19
20
 
21
+ function clamp(text) {
22
+ return text.length > MAX_RESPONSE_LENGTH ? text.slice(0, MAX_RESPONSE_LENGTH) + '...' : text;
23
+ }
24
+
20
25
  function startTyping(channel) {
21
26
  channel.sendTyping().catch(() => {});
22
27
  typingTimer = setInterval(() => {
@@ -86,21 +91,39 @@ function start(config, callbacks) {
86
91
  log(`${parsed.agent}: ${parsed.command}`);
87
92
  startTyping(message.channel);
88
93
 
94
+ let statusReply = null;
95
+ let statusPromise = null;
96
+ let lastEditTime = 0;
97
+
89
98
  try {
90
99
  const result = await chatModule.enqueueMessage(agent.id, parsed.command, {
91
100
  onAck: (text) => {
92
- stopTyping();
93
- message.reply(text).catch(() => {});
94
- startTyping(message.channel);
101
+ text = clamp(text);
102
+ if (!statusPromise) {
103
+ statusPromise = message.reply(text)
104
+ .then(sent => { statusReply = sent; })
105
+ .catch(() => {});
106
+ } else if (Date.now() - lastEditTime >= EDIT_THROTTLE) {
107
+ lastEditTime = Date.now();
108
+ statusPromise = statusPromise.then(() => {
109
+ if (statusReply) return statusReply.edit(text).catch(() => {});
110
+ });
111
+ }
95
112
  }
96
113
  });
97
114
  stopTyping();
98
115
  if (result && result.content) {
99
- let text = result.content;
100
- if (text.length > MAX_RESPONSE_LENGTH) {
101
- text = text.slice(0, MAX_RESPONSE_LENGTH) + '...';
116
+ const text = clamp(result.content);
117
+ if (statusPromise) {
118
+ await statusPromise;
119
+ if (statusReply) {
120
+ await statusReply.edit(text).catch(() => {});
121
+ } else {
122
+ await message.reply(text);
123
+ }
124
+ } else {
125
+ await message.reply(text);
102
126
  }
103
- await message.reply(text);
104
127
  }
105
128
  } catch (err) {
106
129
  stopTyping();
@@ -155,11 +155,19 @@ function poll() {
155
155
  }
156
156
 
157
157
  async function handleCommand(agent, command, chatGuid) {
158
+ let lastAcked = null;
159
+ let lastAckTime = Date.now();
158
160
  try {
159
161
  const result = await chatModule.enqueueMessage(agent.id, command, {
160
- onAck: (text) => sendMessage(chatGuid, text)
162
+ onAck: (text) => {
163
+ if (Date.now() - lastAckTime < 15000) return;
164
+ lastAckTime = Date.now();
165
+ lastAcked = text;
166
+ sendMessage(chatGuid, text);
167
+ }
161
168
  });
162
169
  if (result && result.content) {
170
+ if (lastAcked && (result.content === lastAcked || result.content.startsWith(lastAcked) || lastAcked.startsWith(result.content))) return;
163
171
  sendMessage(chatGuid, result.content);
164
172
  }
165
173
  } catch (err) {
@@ -83,10 +83,19 @@ function startDaemon(phone, onStatus, chatModule, stmts) {
83
83
 
84
84
  log(`${parsed.agent}: ${parsed.command}`);
85
85
 
86
+ let lastAcked = null;
87
+ let lastAckTime = Date.now();
88
+
86
89
  chatModule.enqueueMessage(agent.id, parsed.command, {
87
- onAck: (ackText) => send(sender, ackText)
90
+ onAck: (ackText) => {
91
+ if (Date.now() - lastAckTime < 15000) return;
92
+ lastAckTime = Date.now();
93
+ lastAcked = ackText;
94
+ send(sender, ackText);
95
+ }
88
96
  }).then((result) => {
89
97
  if (result && result.content) {
98
+ if (lastAcked && (result.content === lastAcked || result.content.startsWith(lastAcked) || lastAcked.startsWith(result.content))) return;
90
99
  let reply = result.content;
91
100
  if (reply.length > MAX_RESPONSE_LENGTH) {
92
101
  reply = reply.slice(0, MAX_RESPONSE_LENGTH) + '...';
@@ -2,6 +2,7 @@ const { App, LogLevel } = require('@slack/bolt');
2
2
  const { WebClient } = require('@slack/web-api');
3
3
 
4
4
  const MAX_RESPONSE_LENGTH = 3000;
5
+ const EDIT_THROTTLE = 5000;
5
6
 
6
7
  let app = null;
7
8
 
@@ -17,6 +18,10 @@ function parseMessage(text) {
17
18
  return { agent: match[1].toLowerCase(), command: match[2].trim() };
18
19
  }
19
20
 
21
+ function clamp(text) {
22
+ return text.length > MAX_RESPONSE_LENGTH ? text.slice(0, MAX_RESPONSE_LENGTH) + '...' : text;
23
+ }
24
+
20
25
  function start(config, callbacks) {
21
26
  const { onStatus } = callbacks || {};
22
27
  const { bot_token, app_token } = config || {};
@@ -63,16 +68,39 @@ function start(config, callbacks) {
63
68
 
64
69
  log(`${parsed.agent}: ${parsed.command}`);
65
70
 
71
+ let statusTs = null;
72
+ let statusChannel = null;
73
+ let statusPromise = null;
74
+ let lastEditTime = 0;
75
+
66
76
  try {
67
77
  const result = await chatModule.enqueueMessage(agent.id, parsed.command, {
68
- onAck: (text) => say(text).catch(() => {})
78
+ onAck: (text) => {
79
+ text = clamp(text);
80
+ if (!statusPromise) {
81
+ statusPromise = say(text)
82
+ .then(res => { statusTs = res.ts; statusChannel = res.channel; })
83
+ .catch(() => {});
84
+ } else if (Date.now() - lastEditTime >= EDIT_THROTTLE) {
85
+ lastEditTime = Date.now();
86
+ statusPromise = statusPromise.then(() => {
87
+ if (statusTs) return client.chat.update({ channel: statusChannel, ts: statusTs, text }).catch(() => {});
88
+ });
89
+ }
90
+ }
69
91
  });
70
92
  if (result && result.content) {
71
- let text = result.content;
72
- if (text.length > MAX_RESPONSE_LENGTH) {
73
- text = text.slice(0, MAX_RESPONSE_LENGTH) + '...';
93
+ const text = clamp(result.content);
94
+ if (statusPromise) {
95
+ await statusPromise;
96
+ if (statusTs) {
97
+ await client.chat.update({ channel: statusChannel, ts: statusTs, text }).catch(() => {});
98
+ } else {
99
+ await say(text);
100
+ }
101
+ } else {
102
+ await say(text);
74
103
  }
75
- await say(text);
76
104
  }
77
105
  } catch (err) {
78
106
  await say(`error: ${err.message}`);
@@ -1,6 +1,7 @@
1
1
  const TelegramBot = require('node-telegram-bot-api');
2
2
 
3
3
  const MAX_RESPONSE_LENGTH = 4000;
4
+ const EDIT_THROTTLE = 5000;
4
5
 
5
6
  let bot = null;
6
7
 
@@ -15,6 +16,10 @@ function parseMessage(text) {
15
16
  return { agent: match[1].toLowerCase(), command: match[2].trim() };
16
17
  }
17
18
 
19
+ function clamp(text) {
20
+ return text.length > MAX_RESPONSE_LENGTH ? text.slice(0, MAX_RESPONSE_LENGTH) + '...' : text;
21
+ }
22
+
18
23
  function start(config, callbacks) {
19
24
  const { onStatus } = callbacks || {};
20
25
  const { token } = config || {};
@@ -63,19 +68,40 @@ function start(config, callbacks) {
63
68
  log(`${parsed.agent}: ${parsed.command}`);
64
69
  bot.sendChatAction(msg.chat.id, 'typing').catch(() => {});
65
70
 
71
+ let statusMsgId = null;
72
+ let statusPromise = null;
73
+ let lastEditTime = 0;
74
+
66
75
  try {
67
76
  const result = await chatModule.enqueueMessage(agent.id, parsed.command, {
68
77
  onAck: (text) => {
69
- bot.sendMessage(msg.chat.id, text, { reply_to_message_id: msg.message_id }).catch(() => {});
78
+ text = clamp(text);
79
+ if (!statusPromise) {
80
+ statusPromise = bot.sendMessage(msg.chat.id, text, { reply_to_message_id: msg.message_id })
81
+ .then(sent => { statusMsgId = sent.message_id; })
82
+ .catch(() => {});
83
+ } else if (Date.now() - lastEditTime >= EDIT_THROTTLE) {
84
+ lastEditTime = Date.now();
85
+ statusPromise = statusPromise.then(() => {
86
+ if (statusMsgId) return bot.editMessageText(text, { chat_id: msg.chat.id, message_id: statusMsgId }).catch(() => {});
87
+ });
88
+ }
70
89
  bot.sendChatAction(msg.chat.id, 'typing').catch(() => {});
71
90
  }
72
91
  });
92
+
73
93
  if (result && result.content) {
74
- let text = result.content;
75
- if (text.length > MAX_RESPONSE_LENGTH) {
76
- text = text.slice(0, MAX_RESPONSE_LENGTH) + '...';
94
+ const text = clamp(result.content);
95
+ if (statusPromise) {
96
+ await statusPromise;
97
+ if (statusMsgId) {
98
+ await bot.editMessageText(text, { chat_id: msg.chat.id, message_id: statusMsgId }).catch(() => {});
99
+ } else {
100
+ await bot.sendMessage(msg.chat.id, text, { reply_to_message_id: msg.message_id });
101
+ }
102
+ } else {
103
+ await bot.sendMessage(msg.chat.id, text, { reply_to_message_id: msg.message_id });
77
104
  }
78
- await bot.sendMessage(msg.chat.id, text, { reply_to_message_id: msg.message_id });
79
105
  }
80
106
  } catch (err) {
81
107
  bot.sendMessage(msg.chat.id, `error: ${err.message}`, { reply_to_message_id: msg.message_id }).catch(() => {});
@@ -353,7 +353,7 @@ async function spawnSubagent(input, context) {
353
353
 
354
354
  return new Promise((resolve) => {
355
355
  let done = false;
356
- const args = ['-p', '--output-format', 'json', '--model', model, '--dangerously-skip-permissions'];
356
+ const args = ['-p', '--output-format', 'json', '--model', model, '--dangerously-skip-permissions', '--setting-sources', ''];
357
357
  const proc = spawn('claude', args, {
358
358
  stdio: ['pipe', 'pipe', 'pipe']
359
359
  });
@@ -76,16 +76,26 @@ function start(config, callbacks) {
76
76
  log(`${parsed.agent}: ${parsed.command}`);
77
77
  busy = true;
78
78
 
79
+ let lastAcked = null;
80
+ let lastAckTime = Date.now();
81
+
79
82
  try {
80
83
  const result = await chatModule.enqueueMessage(agent.id, parsed.command, {
81
- onAck: (text) => message.reply(text).catch(() => {})
84
+ onAck: (text) => {
85
+ if (Date.now() - lastAckTime < 15000) return;
86
+ lastAckTime = Date.now();
87
+ lastAcked = text;
88
+ message.reply(text).catch(() => {});
89
+ }
82
90
  });
83
91
  if (result && result.content) {
84
- let text = result.content;
85
- if (text.length > MAX_RESPONSE_LENGTH) {
86
- text = text.slice(0, MAX_RESPONSE_LENGTH) + '...';
92
+ if (!(lastAcked && (result.content === lastAcked || result.content.startsWith(lastAcked) || lastAcked.startsWith(result.content)))) {
93
+ let text = result.content;
94
+ if (text.length > MAX_RESPONSE_LENGTH) {
95
+ text = text.slice(0, MAX_RESPONSE_LENGTH) + '...';
96
+ }
97
+ await message.reply(text);
87
98
  }
88
- await message.reply(text);
89
99
  }
90
100
  } catch (err) {
91
101
  message.reply(`error: ${err.message}`).catch(() => {});
@@ -1,18 +0,0 @@
1
- name: publish
2
- on:
3
- release:
4
- types: [published]
5
- jobs:
6
- publish:
7
- runs-on: ubuntu-latest
8
- permissions:
9
- contents: read
10
- id-token: write
11
- steps:
12
- - uses: actions/checkout@v4
13
- - uses: actions/setup-node@v4
14
- with:
15
- node-version: 20
16
- registry-url: https://registry.npmjs.org
17
- - run: npm install
18
- - run: npm publish --provenance --access public