@teamvibe/poller 0.8.3 → 0.8.4-rc.2

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 (49) hide show
  1. package/README.md +13 -0
  2. package/dist/brain-directory.d.ts +14 -0
  3. package/dist/brain-directory.js +16 -0
  4. package/dist/brain-manager.d.ts +2 -2
  5. package/dist/brain-manager.js +32 -19
  6. package/dist/brain-path.d.ts +9 -0
  7. package/dist/brain-path.js +65 -0
  8. package/dist/claude-spawner.d.ts +3 -2
  9. package/dist/claude-spawner.js +195 -74
  10. package/dist/config.d.ts +4 -0
  11. package/dist/config.js +1 -0
  12. package/dist/event-queue.js +14 -1
  13. package/dist/inbox-manager.d.ts +11 -0
  14. package/dist/inbox-manager.js +101 -6
  15. package/dist/inbox-watcher.d.ts +9 -4
  16. package/dist/inbox-watcher.js +219 -49
  17. package/dist/maintenance-cron.d.ts +4 -2
  18. package/dist/maintenance-cron.js +56 -14
  19. package/dist/metrics/scheduler.d.ts +4 -1
  20. package/dist/metrics/scheduler.js +8 -6
  21. package/dist/poller.d.ts +2 -1
  22. package/dist/poller.js +50 -24
  23. package/dist/pool-logging.js +2 -2
  24. package/dist/pool-telemetry.d.ts +21 -5
  25. package/dist/pool-telemetry.js +28 -11
  26. package/dist/session-store.d.ts +1 -1
  27. package/dist/session-store.js +6 -2
  28. package/dist/startup-auth.js +4 -1
  29. package/dist/subscription-pool-client.js +7 -0
  30. package/dist/subscription-pool.d.ts +1 -0
  31. package/dist/subscription-pool.js +1 -1
  32. package/dist/subscription-runtime.d.ts +21 -3
  33. package/dist/subscription-runtime.js +17 -9
  34. package/dist/subscription-selection.d.ts +20 -3
  35. package/dist/subscription-selection.js +66 -4
  36. package/dist/subscription-usage-store.d.ts +2 -0
  37. package/dist/subscription-usage-store.js +41 -20
  38. package/dist/thread-routing.d.ts +23 -0
  39. package/dist/thread-routing.js +81 -0
  40. package/dist/types.d.ts +4 -10
  41. package/dist/usage-limit-messages.d.ts +1 -1
  42. package/dist/usage-limit-messages.js +1 -1
  43. package/dist/usage-probe-contract.d.ts +15 -0
  44. package/dist/usage-probe-contract.js +14 -0
  45. package/dist/usage-probe-job.js +20 -7
  46. package/dist/worktree-manager.d.ts +2 -1
  47. package/dist/worktree-manager.js +38 -10
  48. package/npm-shrinkwrap.json +2 -2
  49. package/package.json +1 -1
@@ -3,10 +3,10 @@ import { accountUsageTelemetry, emitCandidateUsage, poolRunTelemetry } from './p
3
3
  import { spawn } from 'child_process';
4
4
  import { join } from 'path';
5
5
  import { readFileSync, existsSync } from 'fs';
6
- import { mkdir } from 'fs/promises';
7
6
  import { WebClient } from '@slack/web-api';
8
7
  import { config } from './config.js';
9
8
  import { logger } from './logger.js';
9
+ import { executionDirectory } from './brain-directory.js';
10
10
  import { getBaseBrainPath } from './brain-manager.js';
11
11
  import { getPollerId } from './auth-provider.js';
12
12
  import { sendSlackMessage } from './slack-client.js';
@@ -19,6 +19,44 @@ import { parseClaudeQuotaFailure } from './claude-quota.js';
19
19
  import { accountPin, preparePool, fetchRuntimePool, validateAccount, poolDeferredError, PoolDeferredError } from './subscription-runtime.js';
20
20
  import { selectPoolForResolvedModel } from './subscription-pool.js';
21
21
  import { isQuotaPaused, QuotaPausedError } from './quota-pause.js';
22
+ import { redactStartupText } from './startup-auth.js';
23
+ /** Inspect Claude's user/project settings without changing any brain files. */
24
+ function warnTokenAliasSettings(claudeConfigDir, cwd, brainPath, sessionLog) {
25
+ // CLAUDE_CONFIG_DIR replaces ~/.claude for user settings. settings.local.json
26
+ // is a project source, not a second user-settings file. Recent CLI versions
27
+ // also read local settings from the main checkout when running in a worktree.
28
+ // https://code.claude.com/docs/en/settings
29
+ const paths = new Set([
30
+ join(claudeConfigDir, 'settings.json'),
31
+ join(cwd, '.claude', 'settings.json'),
32
+ join(cwd, '.claude', 'settings.local.json'),
33
+ ...(cwd !== brainPath && existsSync(join(brainPath, '.git'))
34
+ ? [join(brainPath, '.claude', 'settings.local.json')] : []),
35
+ ]);
36
+ const conflicts = [];
37
+ for (const path of paths) {
38
+ let settings;
39
+ try {
40
+ settings = JSON.parse(readFileSync(path, 'utf8'));
41
+ }
42
+ catch {
43
+ // Missing/unreadable/malformed files are CLI concerns. In particular,
44
+ // never log parse errors: their messages can contain setting values.
45
+ continue;
46
+ }
47
+ const env = settings && typeof settings === 'object' && 'env' in settings ? settings.env : undefined;
48
+ if (!env || typeof env !== 'object' || Array.isArray(env))
49
+ continue;
50
+ for (const key of ['TEAMVIBE_CLAUDE_OAUTH_TOKEN', 'TEAMVIBE_POOL_ACCOUNT_ID']) {
51
+ if (Object.hasOwn(env, key)) {
52
+ conflicts.push(`${path} defines env.${key}`);
53
+ }
54
+ }
55
+ }
56
+ if (conflicts.length) {
57
+ sessionLog.warn(`[Claude settings] ${conflicts.join('; ')}; Claude can override the poller-injected environment, including when EXPOSE_CLAUDE_TOKEN_TO_AGENT is off`);
58
+ }
59
+ }
22
60
  /**
23
61
  * Maps tool names (or prefixes) to human-readable status messages.
24
62
  * More specific patterns are checked first.
@@ -197,9 +235,10 @@ function readBrainSettings(cwd) {
197
235
  const raw = readFileSync(settingsPath, 'utf-8');
198
236
  return JSON.parse(raw);
199
237
  }
200
- catch (error) {
201
- logger.warn(`Failed to read brain settings at ${settingsPath}: ${error}`);
202
- emitError('brain', `Failed to read brain settings at ${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, { severity: 'warn' });
238
+ catch {
239
+ // JSON parser errors can quote env values from the source file.
240
+ logger.warn(`Failed to read brain settings at ${settingsPath}`);
241
+ emitError('brain', `Failed to read brain settings at ${settingsPath}`, { severity: 'warn' });
203
242
  return {};
204
243
  }
205
244
  }
@@ -421,6 +460,9 @@ export function sweepProcessGroup(proc, graceMs, options = {}) {
421
460
  * would look like if left uncapped.
422
461
  */
423
462
  export function buildSpawnEnv(params) {
463
+ const exposeToken = /^(1|true|yes|on)$/i.test(params.processEnv['EXPOSE_CLAUDE_TOKEN_TO_AGENT'] ?? '');
464
+ const accountToken = params.subscriptionAccount?.credential.unwrap();
465
+ const sessionToken = accountToken ?? params.processEnv['CLAUDE_CODE_OAUTH_TOKEN'];
424
466
  return {
425
467
  // Secrets platform (#105) injection — SOLE call site of
426
468
  // `SpawnSecretsEnvelope.unwrap()` in the entire poller codebase.
@@ -470,8 +512,13 @@ export function buildSpawnEnv(params) {
470
512
  // drops against this var so they land somewhere still being
471
513
  // watched after the session (and worktree) is gone. See tv#265.
472
514
  POLLER_BRAIN_PATH: params.brainPath,
515
+ // Claude strips its own token name from Bash subprocesses. This alias
516
+ // deliberately opts out of that protection, only at the poller level.
517
+ // Explicit undefined values also clear inherited/overlay aliases.
518
+ TEAMVIBE_CLAUDE_OAUTH_TOKEN: exposeToken ? sessionToken || undefined : undefined,
519
+ TEAMVIBE_POOL_ACCOUNT_ID: exposeToken ? params.subscriptionAccount?.accountId : undefined,
473
520
  ...(params.subscriptionAccount ? {
474
- CLAUDE_CODE_OAUTH_TOKEN: params.subscriptionAccount.credential.unwrap(),
521
+ CLAUDE_CODE_OAUTH_TOKEN: accountToken,
475
522
  ANTHROPIC_API_KEY: undefined,
476
523
  ANTHROPIC_AUTH_TOKEN: undefined,
477
524
  ANTHROPIC_BASE_URL: undefined,
@@ -800,7 +847,7 @@ function resultMetrics(event, pooled, lastAssistant) {
800
847
  metrics.context_window = window;
801
848
  return metrics;
802
849
  }
803
- async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, runtime) {
850
+ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, runtime) {
804
851
  const slackContext = msg.response_context.slack;
805
852
  const isCronMessage = msg.source === 'cron';
806
853
  const isMaintenance = msg.source === 'maintenance';
@@ -941,25 +988,44 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
941
988
  : process.env['PATH'];
942
989
  if (runtime?.account)
943
990
  poolInfo(sessionLog, `token target account=${poolLabel(runtime.account)} session=${JSON.stringify(sessionId ?? 'new')} credential=selected account token`);
991
+ const spawnEnv = buildSpawnEnv({
992
+ spawnSecretsEnv: spawnSecrets.env.unwrap(),
993
+ processEnv: process.env,
994
+ envPath,
995
+ claudeConfigDir: runtime?.claudeConfigDir ?? getBaseBrainPath(),
996
+ persistentStoragePath: config.PERSISTENT_STORAGE_PATH,
997
+ slackContext,
998
+ teamvibeApiUrl: config.TEAMVIBE_API_URL,
999
+ teamvibePollerToken: config.TEAMVIBE_POLLER_TOKEN || '',
1000
+ teamvibeWorkspaceId: msg.teamvibe.workspaceId,
1001
+ teamvibeChannelId: msg.teamvibe.channelId,
1002
+ teamvibeBotId: msg.teamvibe.botId,
1003
+ teamvibePollerId: getPollerId() || '',
1004
+ inboxThreadId: msg.thread_id,
1005
+ brainPath: directory.path,
1006
+ subscriptionAccount: runtime?.account,
1007
+ });
1008
+ // Capture the actual credentials for this attempt, including pool selection
1009
+ // and secrets overlays, regardless of whether alias exposure is enabled.
1010
+ const outputTokens = [spawnEnv['CLAUDE_CODE_OAUTH_TOKEN'], spawnEnv['TEAMVIBE_CLAUDE_OAUTH_TOKEN']]
1011
+ .filter((token) => !!token);
1012
+ const redactOutput = (text) => redactStartupText(text, outputTokens);
1013
+ const safeEvent = (value) => {
1014
+ if (typeof value === 'string')
1015
+ return redactOutput(value);
1016
+ if (Array.isArray(value))
1017
+ return value.map(safeEvent);
1018
+ if (!value || typeof value !== 'object')
1019
+ return value;
1020
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
1021
+ redactOutput(key),
1022
+ /^(?:CLAUDE_CODE_OAUTH_TOKEN|TEAMVIBE_CLAUDE_OAUTH_TOKEN)$/i.test(key) ? '[REDACTED]' : safeEvent(entry),
1023
+ ]));
1024
+ };
1025
+ warnTokenAliasSettings(spawnEnv['CLAUDE_CONFIG_DIR'], cwd, directory.path, sessionLog);
944
1026
  const proc = spawn(config.CLAUDE_CLI_PATH, args, {
945
1027
  cwd,
946
- env: buildSpawnEnv({
947
- spawnSecretsEnv: spawnSecrets.env.unwrap(),
948
- processEnv: process.env,
949
- envPath,
950
- claudeConfigDir: runtime?.claudeConfigDir ?? getBaseBrainPath(),
951
- persistentStoragePath: config.PERSISTENT_STORAGE_PATH,
952
- slackContext,
953
- teamvibeApiUrl: config.TEAMVIBE_API_URL,
954
- teamvibePollerToken: config.TEAMVIBE_POLLER_TOKEN || '',
955
- teamvibeWorkspaceId: msg.teamvibe.workspaceId,
956
- teamvibeChannelId: msg.teamvibe.channelId,
957
- teamvibeBotId: msg.teamvibe.botId,
958
- teamvibePollerId: getPollerId() || '',
959
- inboxThreadId: msg.thread_id,
960
- brainPath,
961
- subscriptionAccount: runtime?.account,
962
- }),
1028
+ env: spawnEnv,
963
1029
  stdio: ['pipe', 'pipe', 'pipe'],
964
1030
  // teamvibeai/teamvibe.ai#73: run `claude` (via setsid) as the leader of
965
1031
  // its own new process group, separate from the poller's. This is what
@@ -980,6 +1046,12 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
980
1046
  proc.stdin?.end();
981
1047
  let stdout = '';
982
1048
  let stderr = '';
1049
+ let stderrLogBuffer = '';
1050
+ const flushStderrLog = () => {
1051
+ if (stderrLogBuffer)
1052
+ sessionLog.claude('stderr', redactOutput(stderrLogBuffer).trimEnd());
1053
+ stderrLogBuffer = '';
1054
+ };
983
1055
  let stdoutBuffer = '';
984
1056
  // Retain CLI error/result evidence, not the full parsed assistant/tool stream.
985
1057
  const failureEvents = [];
@@ -1039,12 +1111,15 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1039
1111
  const lines = completeData.split('\n').filter((line) => line.trim());
1040
1112
  for (const line of lines) {
1041
1113
  try {
1042
- const event = JSON.parse(line);
1043
- if (event?.type === 'rate_limit_event') {
1114
+ const rawEvent = JSON.parse(line);
1115
+ // Redact before formatting/truncation or forwarding any string fields
1116
+ // (including metadata keys) to logs, telemetry, typing, or Slack.
1117
+ const event = safeEvent(rawEvent);
1118
+ if (rawEvent?.type === 'rate_limit_event') {
1044
1119
  rateLimitInfo = parseClaudeRateLimitInfo(event.rate_limit_info);
1045
1120
  }
1046
- if (event?.type === 'result' || event?.type === 'error' || (event?.type === 'assistant' && event.error)) {
1047
- failureEvents.push(event);
1121
+ if (rawEvent?.type === 'result' || rawEvent?.type === 'error' || (rawEvent?.type === 'assistant' && rawEvent.error)) {
1122
+ failureEvents.push(rawEvent);
1048
1123
  }
1049
1124
  // Capture the pre-event activity timestamp BEFORE the unconditional
1050
1125
  // overwrite below — needed to compute the "original 10min deadline
@@ -1093,7 +1168,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1093
1168
  }
1094
1169
  const formatted = formatStreamEvent(event);
1095
1170
  if (formatted) {
1096
- sessionLog.claude('stdout', formatted);
1171
+ sessionLog.claude('stdout', redactOutput(formatted));
1097
1172
  }
1098
1173
  // teamvibe.ai#284: track the last non-empty plain-text assistant
1099
1174
  // content seen, regardless of tool calls — candidate for the
@@ -1131,7 +1206,8 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1131
1206
  }
1132
1207
  }
1133
1208
  catch {
1134
- const truncated = line.length > 500 ? line.slice(0, 500) + '...' : line;
1209
+ const safeLine = redactOutput(line);
1210
+ const truncated = safeLine.length > 500 ? safeLine.slice(0, 500) + '...' : safeLine;
1135
1211
  sessionLog.claude('stdout', truncated.trimEnd());
1136
1212
  }
1137
1213
  }
@@ -1140,7 +1216,14 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1140
1216
  proc.stderr.on('data', (data) => {
1141
1217
  const chunk = data.toString();
1142
1218
  stderr += chunk;
1143
- sessionLog.claude('stderr', chunk.trimEnd());
1219
+ // Wait for complete lines so a token split across data events cannot
1220
+ // escape redaction. Flush a final unterminated line on close/error.
1221
+ stderrLogBuffer += chunk;
1222
+ const lastNewline = stderrLogBuffer.lastIndexOf('\n');
1223
+ if (lastNewline !== -1) {
1224
+ sessionLog.claude('stderr', redactOutput(stderrLogBuffer.slice(0, lastNewline + 1)).trimEnd());
1225
+ stderrLogBuffer = stderrLogBuffer.slice(lastNewline + 1);
1226
+ }
1144
1227
  // Track continuous network errors for early kill
1145
1228
  if (NETWORK_ERROR_PATTERNS.test(chunk)) {
1146
1229
  if (networkErrorStart === null) {
@@ -1249,16 +1332,19 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1249
1332
  // not something shutdown() awaits.
1250
1333
  void sweepProcessGroup(proc, CLOSE_SWEEP_GRACE_MS, { unref: true });
1251
1334
  const killReason = killReasonByProc.get(proc);
1335
+ flushStderrLog();
1252
1336
  // Preserve a final unterminated stream-json error/result and its metrics.
1253
1337
  if (stdoutBuffer.trim()) {
1254
1338
  try {
1255
- const event = JSON.parse(stdoutBuffer);
1339
+ const rawEvent = JSON.parse(stdoutBuffer);
1340
+ const event = safeEvent(rawEvent);
1256
1341
  if (event?.type === 'rate_limit_event') {
1257
1342
  rateLimitInfo = parseClaudeRateLimitInfo(event.rate_limit_info);
1258
1343
  }
1259
- failureEvents.push(event);
1260
- if (event?.type === 'assistant')
1344
+ failureEvents.push(rawEvent);
1345
+ if (event?.type === 'assistant') {
1261
1346
  lastAssistant = { usage: event.message?.usage, model: event.message?.model };
1347
+ }
1262
1348
  if (event?.type === 'result')
1263
1349
  metrics = resultMetrics(event, !!runtime?.account, lastAssistant);
1264
1350
  }
@@ -1272,7 +1358,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1272
1358
  attemptedSessionId: sessionId,
1273
1359
  ...(quota ? { quota } : {}),
1274
1360
  ...(rateLimitInfo ? { rateLimitInfo } : {}),
1275
- output: stdout,
1361
+ output: redactOutput(stdout),
1276
1362
  exitCode: code,
1277
1363
  metrics,
1278
1364
  modelSource,
@@ -1305,25 +1391,26 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1305
1391
  // buildSpawnEnv above), a future OOM is more likely to surface this
1306
1392
  // way — a cgroup-level SIGKILL — rather than as a V8-level FATAL
1307
1393
  // ERROR visible in stderr, so this is the replacement visibility.
1308
- result.error = stderr || (signal ? `Process was killed by signal ${signal}` : `Process exited with code ${code}`);
1394
+ result.error = redactOutput(stderr || (signal ? `Process was killed by signal ${signal}` : `Process exited with code ${code}`));
1309
1395
  }
1310
1396
  resolve(result);
1311
1397
  });
1312
1398
  proc.on('error', (error) => {
1399
+ flushStderrLog();
1313
1400
  clearTimeout(timeout);
1314
1401
  clearInterval(idleWatchdog);
1315
1402
  activeProcesses.delete(msg.id);
1316
1403
  activeProcessesByThread.delete(msg.thread_id);
1317
1404
  lastSessionEndTs = Date.now();
1318
- emitError('session', `Claude Code process spawn error: ${error.message}`, {
1405
+ emitError('session', `Claude Code process spawn error: ${redactOutput(error.message)}`, {
1319
1406
  channelId: msg.teamvibe.channelId,
1320
1407
  context: { threadId: msg.thread_id },
1321
1408
  });
1322
1409
  resolve({
1323
1410
  success: false,
1324
- output: stdout,
1411
+ output: redactOutput(stdout),
1325
1412
  exitCode: null,
1326
- error: error.message,
1413
+ error: redactOutput(error.message),
1327
1414
  ...(rateLimitInfo ? { rateLimitInfo } : {}),
1328
1415
  attemptedSessionId: sessionId,
1329
1416
  sawSendMessageCall,
@@ -1361,7 +1448,9 @@ export function finalizeSpawnResult(retryResult, isFirstMessage, effectiveSessio
1361
1448
  }
1362
1449
  return retryResult;
1363
1450
  }
1364
- export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator) {
1451
+ export async function spawnClaudeCode(msg, sessionLog, selectedDirectory, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator) {
1452
+ const directory = executionDirectory(selectedDirectory);
1453
+ const cwd = directory.path;
1365
1454
  // Worktree isolation: spawned (non-foreground) sessions run in
1366
1455
  // <brain>/.worktrees/<thread_id>/ so the sync-watchdog's reset --hard on
1367
1456
  // the main working dir can't trample in-flight writes or pre-push commits.
@@ -1387,12 +1476,12 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
1387
1476
  const { randomUUID } = await import('crypto');
1388
1477
  effectiveSessionId = randomUUID();
1389
1478
  }
1390
- const result = await runClaudeCode(msg, sessionLog, effectiveCwd, cwd, effectiveSessionId, isFirstMessage, lastMessageTs, onMessageSent, typingIndicator);
1479
+ const result = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, effectiveSessionId, isFirstMessage, lastMessageTs, onMessageSent, typingIndicator);
1391
1480
  // If --resume failed, retry as a fresh session (session files may have been lost on container restart)
1392
1481
  let retryResult = result;
1393
1482
  if (!result.success && !result.quota && effectiveSessionId && !isFirstMessage) {
1394
1483
  sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
1395
- retryResult = await runClaudeCode(msg, sessionLog, effectiveCwd, cwd, effectiveSessionId, true, lastMessageTs, onMessageSent, typingIndicator);
1484
+ retryResult = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, effectiveSessionId, true, lastMessageTs, onMessageSent, typingIndicator);
1396
1485
  }
1397
1486
  // If session ID is "already in use" (stale lock file), retry immediately with a
1398
1487
  // fresh ID — no zombie kill first. Zombie cleanup is #73's concern, not spawn
@@ -1401,7 +1490,7 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
1401
1490
  sessionLog.info('Session ID already in use (stale lock), retrying with fresh session ID');
1402
1491
  const { randomUUID } = await import('crypto');
1403
1492
  const freshId = randomUUID();
1404
- const freshResult = await runClaudeCode(msg, sessionLog, effectiveCwd, cwd, freshId, true, lastMessageTs, onMessageSent, typingIndicator);
1493
+ const freshResult = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, freshId, true, lastMessageTs, onMessageSent, typingIndicator);
1405
1494
  return finalizeSpawnResult(freshResult, true, freshId);
1406
1495
  }
1407
1496
  return finalizeSpawnResult(retryResult, isFirstMessage, effectiveSessionId);
@@ -1415,44 +1504,51 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
1415
1504
  }
1416
1505
  }
1417
1506
  // Configured-pool orchestration is deliberately separate from legacy spawns.
1418
- export async function spawnPoolClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, poolRuntime) {
1507
+ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, poolRuntime) {
1508
+ const directory = executionDirectory(selectedDirectory);
1509
+ const cwd = directory.path;
1419
1510
  // Worktree isolation: spawned (non-foreground) sessions run in
1420
1511
  // <brain>/.worktrees/<thread_id>/ so the sync-watchdog's reset --hard on
1421
1512
  // the main working dir can't trample in-flight writes or pre-push commits.
1422
1513
  // See teamvibe.ai#120. cwd remains the main brain path on fallback / failure.
1514
+ const claudeConfigDir = getBaseBrainPath();
1515
+ const isolate = shouldIsolateInWorktree(msg);
1516
+ const derivedCwd = isolate ? join(cwd, '.worktrees', msg.thread_id) : cwd;
1517
+ const pin = poolRuntime?.pin;
1518
+ // Persisted directories are agent-writable evidence, never execution inputs.
1519
+ // Compare against the same platform-selected paths used by a fresh spawn.
1520
+ if (pin?.resumePending && (pin.effectiveCwd !== undefined && pin.effectiveCwd !== derivedCwd ||
1521
+ pin.claudeConfigDir !== undefined && pin.claudeConfigDir !== claudeConfigDir)) {
1522
+ sessionLog.warn('[Pool] Pending session directory mismatch; starting a fresh session in the current channel brain');
1523
+ emitError('session', 'Pending session directory mismatch; starting a fresh session', {
1524
+ severity: 'warn', channelId: msg.teamvibe.channelId,
1525
+ context: { workspaceId: msg.teamvibe.workspaceId, threadId: msg.thread_id, reason: 'directory_mismatch' },
1526
+ });
1527
+ poolRuntime = { ...poolRuntime, pin: { ...pin, resumePending: false, sawSendMessageCall: false } };
1528
+ sessionId = undefined;
1529
+ isFirstMessage = true;
1530
+ }
1531
+ const resumePending = poolRuntime?.pin?.resumePending === true;
1423
1532
  let effectiveCwd = cwd;
1424
1533
  let loggingPool = false;
1425
- let retainWorktree = poolRuntime?.pin?.resumePending === true;
1534
+ let retainWorktree = resumePending;
1426
1535
  const poolAttempts = poolRuntime?.attempts?.slice(-1000) ?? [];
1427
1536
  let worktreeOwner;
1428
- if (poolRuntime?.pin?.resumePending && poolRuntime.pin.effectiveCwd) {
1429
- const pinnedCwd = poolRuntime.pin.effectiveCwd;
1430
- if (pinnedCwd !== cwd && pinnedCwd !== join(cwd, '.worktrees', msg.thread_id))
1431
- throw new PoolDeferredError();
1432
- if (!existsSync(pinnedCwd)) {
1433
- sessionLog.warn(`[Worktree] Pinned worktree missing; recreating ${pinnedCwd} for pending session ${sessionId ?? 'unknown'}`);
1434
- try {
1435
- if (pinnedCwd === cwd)
1436
- await mkdir(cwd, { recursive: true });
1437
- else
1438
- await createWorktree(cwd, msg.thread_id);
1439
- }
1440
- catch {
1441
- sessionLog.warn('[Worktree] Pinned worktree recreation failed; pending work retained for the next recheck');
1442
- throw new PoolDeferredError();
1443
- }
1444
- }
1445
- effectiveCwd = pinnedCwd;
1446
- if (effectiveCwd !== cwd)
1447
- worktreeOwner = { brainPath: cwd, id: msg.thread_id };
1448
- }
1449
- else if (shouldIsolateInWorktree(msg)) {
1537
+ if (isolate) {
1450
1538
  try {
1451
- effectiveCwd = await createWorktree(cwd, msg.thread_id);
1539
+ // Retain pending work only at the independently derived worktree path.
1540
+ if (resumePending && !existsSync(derivedCwd)) {
1541
+ sessionLog.warn(`[Worktree] Pinned worktree missing; recreating ${derivedCwd} for pending session ${sessionId ?? 'unknown'}`);
1542
+ }
1543
+ effectiveCwd = resumePending && existsSync(derivedCwd) ? derivedCwd : await createWorktree(cwd, msg.thread_id);
1452
1544
  worktreeOwner = { brainPath: cwd, id: msg.thread_id };
1453
1545
  sessionLog.info(`[Worktree] Spawned session running in ${effectiveCwd}`);
1454
1546
  }
1455
1547
  catch (err) {
1548
+ if (resumePending) {
1549
+ sessionLog.warn('[Worktree] Pending worktree recreation failed; retaining work for the next recheck');
1550
+ throw new PoolDeferredError();
1551
+ }
1456
1552
  sessionLog.warn(`[Worktree] Failed to create worktree, falling back to main brain dir: ${err instanceof Error ? err.message : err}`);
1457
1553
  }
1458
1554
  }
@@ -1475,7 +1571,6 @@ export async function spawnPoolClaudeCode(msg, sessionLog, cwd, sessionId, isFir
1475
1571
  ? { model: poolRuntime.pin.model, modelSource: poolRuntime.pin.modelSource }
1476
1572
  : resolveModel(typeof settings['model'] === 'string' ? settings['model'] : undefined, scoped ?? {}, config.CLAUDE_MODEL);
1477
1573
  const prepared = poolRuntime ? await preparePool(msg, resolved, poolRuntime.pin, fetchedPool, sessionLog) : undefined;
1478
- const claudeConfigDir = poolRuntime?.pin?.resumePending ? poolRuntime.pin.claudeConfigDir ?? getBaseBrainPath() : getBaseBrainPath();
1479
1574
  const modelNormalizedFrom = scoped?.model === resolved.model && scoped?.modelSource === resolved.modelSource
1480
1575
  ? scoped?.modelNormalizedFrom : undefined;
1481
1576
  const runtime = poolRuntime ? { resolved, modelNormalizedFrom, claudeConfigDir, account: prepared?.account, shouldStop: poolRuntime.shouldStop, resumePending: fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime.pin?.resumePending === true } : undefined;
@@ -1493,12 +1588,32 @@ export async function spawnPoolClaudeCode(msg, sessionLog, cwd, sessionId, isFir
1493
1588
  // fallback must report the fresh attempt's delivery flags independently.
1494
1589
  if (!runtime?.resumePending)
1495
1590
  sawSendMessageCall = false;
1496
- const result = await runClaudeCode(msg, sessionLog, effectiveCwd, cwd, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime);
1591
+ const spawned = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime);
1592
+ const result = prepared?.account && spawned.rateLimitInfo?.status === 'rejected'
1593
+ ? { ...spawned, success: false } : spawned;
1497
1594
  sawSendMessageCall ||= result.sawSendMessageCall === true;
1498
1595
  if (prepared?.account) {
1499
1596
  if (poolAttempts.length >= 1000)
1500
1597
  poolAttempts.shift();
1501
- poolAttempts.push({ workspaceId: prepared.account.workspaceId, accountId: prepared.account.accountId, credentialVersion: prepared.account.credentialVersion, accountLabel: prepared.account.label ?? prepared.account.accountId, observedAt: Date.now(), rateLimitInfo: result.rateLimitInfo, sessionId: id, model: resolved.model ?? result.metrics?.model, success: result.success, exitCode: result.exitCode, killedByPoller: result.killedByPoller, quota: result.quota, metrics: result.metrics });
1598
+ const selection = prepared.selection;
1599
+ poolAttempts.push({
1600
+ ...(selection?.kind === 'selected' && selection.provenance === 'known'
1601
+ ? { score: selection.score, rateWindow: selection.rateWindow, reason: selection.reason }
1602
+ : {}),
1603
+ workspaceId: prepared.account.workspaceId,
1604
+ accountId: prepared.account.accountId,
1605
+ credentialVersion: prepared.account.credentialVersion,
1606
+ accountLabel: prepared.account.label ?? prepared.account.accountId,
1607
+ observedAt: Date.now(),
1608
+ rateLimitInfo: result.rateLimitInfo,
1609
+ sessionId: id,
1610
+ model: resolved.model ?? result.metrics?.model,
1611
+ success: result.success,
1612
+ exitCode: result.exitCode,
1613
+ killedByPoller: result.killedByPoller,
1614
+ quota: result.quota,
1615
+ metrics: result.metrics,
1616
+ });
1502
1617
  emitEvent('pool.account_attempted', msg.teamvibe.channelId, { workspaceId: msg.teamvibe.workspaceId, threadId: msg.thread_id, ...poolRunTelemetry(poolAttempts, poolRuntime?.quotaDeferEpisodes) });
1503
1618
  if (result.success && id && poolRuntime) {
1504
1619
  // Complete the inbox before any fallible post-inference store writes.
@@ -1509,7 +1624,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, cwd, sessionId, isFir
1509
1624
  retainWorktree = false;
1510
1625
  return result;
1511
1626
  }
1512
- if (result.quota && !result.killedByPoller) {
1627
+ if ((result.quota || result.rateLimitInfo?.status === 'rejected') && !result.killedByPoller) {
1513
1628
  runtime.resumePending = true;
1514
1629
  retainWorktree = true;
1515
1630
  }
@@ -1532,7 +1647,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, cwd, sessionId, isFir
1532
1647
  let switched = false;
1533
1648
  const finishQuota = async (initial) => {
1534
1649
  let result = initial;
1535
- while (prepared?.account && result.quota && !result.killedByPoller && poolRuntime && runtime) {
1650
+ while (prepared?.account && (result.quota || result.rateLimitInfo?.status === 'rejected') && !result.killedByPoller && poolRuntime && runtime) {
1536
1651
  attemptedAccounts.add(prepared.account.accountId);
1537
1652
  if (poolRuntime.shouldStop() || attemptedAccounts.size >= 1000)
1538
1653
  throw new PoolDeferredError();
@@ -1540,7 +1655,8 @@ export async function spawnPoolClaudeCode(msg, sessionLog, cwd, sessionId, isFir
1540
1655
  logPoolFetch(sessionLog, prepared.pool);
1541
1656
  if (prepared.pool.kind === 'ready')
1542
1657
  await emitCandidateUsage(msg, prepared.pool.accounts, prepared.store);
1543
- // Reactive evidence was persisted by attempt() before choosing another account.
1658
+ // Quota and passive rejection evidence were persisted before selection.
1659
+ // The chain also excludes attempted accounts even if a concurrent observation replaces that evidence.
1544
1660
  if (prepared.pool.kind === 'ready')
1545
1661
  for (const account of prepared.pool.accounts) {
1546
1662
  if (attemptedAccounts.has(account.accountId))
@@ -1556,11 +1672,16 @@ export async function spawnPoolClaudeCode(msg, sessionLog, cwd, sessionId, isFir
1556
1672
  const fromAccount = prepared.account;
1557
1673
  poolInfo(sessionLog, `switch from=${poolLabel(fromAccount)} to=${poolLabel(selected.account)} session=${JSON.stringify(effectiveSessionId)}`);
1558
1674
  prepared.account = selected.account;
1675
+ prepared.selection = selected;
1559
1676
  runtime.account = selected.account;
1560
1677
  runtime.resumePending = true;
1561
1678
  switched = true;
1562
1679
  const selectedUsage = await prepared.store.read(selected.account).catch(() => ({ reactive: [], passive: undefined }));
1563
- emitEvent('pool.account_switched', msg.teamvibe.channelId, { workspaceId: msg.teamvibe.workspaceId, threadId: msg.thread_id, ...poolRunTelemetry(poolAttempts, poolRuntime.quotaDeferEpisodes), ...accountUsageTelemetry(selected.account, selectedUsage), lastRateLimitInfo: selectedUsage.passive?.rateLimitInfo, observedAt: selectedUsage.passive?.observedAt, fromAccountId: fromAccount.accountId, fromAccountLabel: fromAccount.label ?? fromAccount.accountId, model: resolved.model, chainLength: poolAttempts.length + 1, switchedAccounts: true });
1680
+ // A switch reports the newly selected account's decision.
1681
+ const selectionDetails = selected.provenance === 'known'
1682
+ ? { score: selected.score, reason: selected.reason, rateWindow: { window: selected.rateWindow.window, scope: selected.rateWindow.scope, percent: selected.rateWindow.percent, resetAt: selected.rateWindow.resetAt } }
1683
+ : { score: undefined, reason: undefined, rateWindow: undefined };
1684
+ emitEvent('pool.account_switched', msg.teamvibe.channelId, { workspaceId: msg.teamvibe.workspaceId, threadId: msg.thread_id, ...poolRunTelemetry(poolAttempts, poolRuntime.quotaDeferEpisodes), ...selectionDetails, ...accountUsageTelemetry(selected.account, selectedUsage), lastRateLimitInfo: selectedUsage.passive?.rateLimitInfo, observedAt: selectedUsage.passive?.observedAt, fromAccountId: fromAccount.accountId, fromAccountLabel: fromAccount.label ?? fromAccount.accountId, model: resolved.model, chainLength: poolAttempts.length + 1, switchedAccounts: true });
1564
1685
  result = await attempt(effectiveSessionId, false);
1565
1686
  sawSendMessageCall ||= result.sawSendMessageCall === true;
1566
1687
  }
package/dist/config.d.ts CHANGED
@@ -41,6 +41,7 @@ declare const configSchema: z.ZodObject<{
41
41
  AUTO_UPDATE_POLICY: z.ZodDefault<z.ZodEnum<["patch", "minor", "off"]>>;
42
42
  AUTO_UPDATE_SCHEDULE: z.ZodDefault<z.ZodString>;
43
43
  INBOX_WATCH_FALLBACK_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
44
+ INBOX_ROUTING_TTL_DAYS: z.ZodDefault<z.ZodNumber>;
44
45
  INBOX_WATCH_DEBOUNCE_MS: z.ZodDefault<z.ZodNumber>;
45
46
  IDLE_RESTART_ENABLED: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
46
47
  IDLE_RESTART_IDLE_MS: z.ZodDefault<z.ZodNumber>;
@@ -83,6 +84,7 @@ declare const configSchema: z.ZodObject<{
83
84
  AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
84
85
  AUTO_UPDATE_SCHEDULE: string;
85
86
  INBOX_WATCH_FALLBACK_INTERVAL_MS: number;
87
+ INBOX_ROUTING_TTL_DAYS: number;
86
88
  INBOX_WATCH_DEBOUNCE_MS: number;
87
89
  IDLE_RESTART_ENABLED: boolean;
88
90
  IDLE_RESTART_IDLE_MS: number;
@@ -133,6 +135,7 @@ declare const configSchema: z.ZodObject<{
133
135
  AUTO_UPDATE_POLICY?: "patch" | "minor" | "off" | undefined;
134
136
  AUTO_UPDATE_SCHEDULE?: string | undefined;
135
137
  INBOX_WATCH_FALLBACK_INTERVAL_MS?: number | undefined;
138
+ INBOX_ROUTING_TTL_DAYS?: number | undefined;
136
139
  INBOX_WATCH_DEBOUNCE_MS?: number | undefined;
137
140
  IDLE_RESTART_ENABLED?: string | undefined;
138
141
  IDLE_RESTART_IDLE_MS?: number | undefined;
@@ -178,6 +181,7 @@ export declare const config: {
178
181
  AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
179
182
  AUTO_UPDATE_SCHEDULE: string;
180
183
  INBOX_WATCH_FALLBACK_INTERVAL_MS: number;
184
+ INBOX_ROUTING_TTL_DAYS: number;
181
185
  INBOX_WATCH_DEBOUNCE_MS: number;
182
186
  IDLE_RESTART_ENABLED: boolean;
183
187
  IDLE_RESTART_IDLE_MS: number;
package/dist/config.js CHANGED
@@ -80,6 +80,7 @@ const configSchema = z.object({
80
80
  // catches missed/coalesced events, picks up brains whose .inbox/ didn't
81
81
  // exist at watcher startup, and prunes empty thread dirs.
82
82
  INBOX_WATCH_FALLBACK_INTERVAL_MS: z.coerce.number().default(300_000), // 5 minutes
83
+ INBOX_ROUTING_TTL_DAYS: z.coerce.number().positive().default(14),
83
84
  INBOX_WATCH_DEBOUNCE_MS: z.coerce.number().default(300), // coalesce bursty fs events into one scan
84
85
  // Idle restart (tv#317): Railway bills on cgroup memory usage, which includes page
85
86
  // cache that Linux only reclaims under real memory pressure — a large Railway memory
@@ -34,7 +34,7 @@ function enqueue(event) {
34
34
  }
35
35
  async function flushOne(event) {
36
36
  try {
37
- const response = await fetch(`${config.TEAMVIBE_API_URL}/events`, {
37
+ const post = () => fetch(`${config.TEAMVIBE_API_URL}/events`, {
38
38
  method: 'POST',
39
39
  headers: {
40
40
  'Content-Type': 'application/json',
@@ -47,6 +47,19 @@ async function flushOne(event) {
47
47
  error: event.error,
48
48
  }),
49
49
  });
50
+ let response = await post();
51
+ if (response.status === 400 &&
52
+ event.eventType === 'pool.probe_failed' &&
53
+ event.channelId === 'usage_probe' &&
54
+ event.metadata?.['source'] === 'probe' &&
55
+ event.metadata['requestId'] &&
56
+ event.metadata['reason'] !== 'unknown') {
57
+ // Pre-#480 platforms accept unknown but reject the new account reasons.
58
+ // Keep the downgrade on queued retries if the second post is transiently unavailable.
59
+ event.metadata = { ...event.metadata, reason: 'unknown' };
60
+ logger.info(`[Pool] usage probe request=${event.metadata['requestId']} completion downgraded for legacy platform`);
61
+ response = await post();
62
+ }
50
63
  return response.ok;
51
64
  }
52
65
  catch {
@@ -53,6 +53,11 @@ export declare function requeueMessage(brainPath: string, threadId: string, file
53
53
  */
54
54
  export declare function hasUnprocessed(brainPath: string, threadId: string): boolean;
55
55
  export declare function saveInboxContext(brainPath: string, msg: TeamVibeQueueMessage): void;
56
+ /** Even partial envelopes carrying channel context must never use a singleton guess. */
57
+ export declare function readInboxChannelContext(brainPath: string, threadId: string): {
58
+ workspaceId: unknown;
59
+ channelId: unknown;
60
+ } | undefined;
56
61
  export declare function readInboxContext(brainPath: string, threadId: string): TeamVibeQueueMessage | undefined;
57
62
  export interface InboxRuntime {
58
63
  sessionId: string;
@@ -70,3 +75,9 @@ export interface InboxRuntime {
70
75
  export declare function saveInboxRuntime(brainPath: string, threadId: string, state: InboxRuntime): void;
71
76
  export declare function readInboxRuntime(brainPath: string, threadId: string): InboxRuntime | undefined;
72
77
  export declare function clearInboxRuntime(brainPath: string, threadId: string): void;
78
+ /** Remove transfer remnants and completed copies before discovery or replay. */
79
+ export declare function discardHandledInboxDrops(brainPath: string, threadId: string): void;
80
+ /** Move pending content only; a different brain must never inherit continuation state. */
81
+ export declare function restartInboxInDirectory(source: string, target: string, threadId: string): void;
82
+ /** Preserve unroutable work for operator review and remove it from replay queues. */
83
+ export declare function quarantineInbox(brainPath: string, threadId: string, includeFailed?: boolean): void;