@teamvibe/poller 0.8.7 → 0.8.9-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +5 -0
  2. package/bin/tv-git-credential.mjs +11 -6
  3. package/dist/brain-manager.js +4 -4
  4. package/dist/claude-spawner.d.ts +4 -2
  5. package/dist/claude-spawner.js +50 -16
  6. package/dist/config.d.ts +4 -0
  7. package/dist/config.js +1 -0
  8. package/dist/connections-client.d.ts +16 -4
  9. package/dist/connections-client.js +42 -11
  10. package/dist/end-of-turn-sentinel.d.ts +2 -0
  11. package/dist/end-of-turn-sentinel.js +2 -0
  12. package/dist/git-credential-bindings.d.ts +2 -0
  13. package/dist/git-credential-bindings.js +4 -0
  14. package/dist/git-credential-env.d.ts +2 -2
  15. package/dist/git-credential-env.js +22 -9
  16. package/dist/git-credential-session.d.ts +6 -6
  17. package/dist/git-credential-session.js +24 -4
  18. package/dist/inbox-watcher.d.ts +2 -2
  19. package/dist/inbox-watcher.js +20 -13
  20. package/dist/poller.d.ts +4 -3
  21. package/dist/poller.js +63 -11
  22. package/dist/redact-text.js +1 -1
  23. package/dist/slack-client.d.ts +3 -1
  24. package/dist/slack-client.js +84 -12
  25. package/dist/subscription-runtime.d.ts +2 -0
  26. package/dist/subscription-runtime.js +6 -2
  27. package/dist/subscription-selection.d.ts +21 -2
  28. package/dist/subscription-selection.js +91 -14
  29. package/dist/subscription-usage-store.d.ts +6 -1
  30. package/dist/subscription-usage-store.js +246 -13
  31. package/dist/thread-routing.d.ts +7 -0
  32. package/dist/thread-routing.js +7 -0
  33. package/dist/types.d.ts +2 -0
  34. package/dist/usage-limit-messages.d.ts +3 -0
  35. package/dist/usage-limit-messages.js +22 -0
  36. package/dist/usage-probe-job.js +31 -3
  37. package/npm-shrinkwrap.json +2 -2
  38. package/package.json +1 -1
package/README.md CHANGED
@@ -99,3 +99,8 @@ The poller authenticates with the TeamVibe API using your token, then continuous
99
99
  ## Subscription usage freshness
100
100
 
101
101
  Passive usage percentages remain usable lower bounds until each window's `resetAt`. Without a reset time, passive windows expire five minutes after observation. Probe windows retain their 60-second TTL. Reset windows are ignored; known accounts still rank before unknown accounts. A window at the account's threshold defers selection until its expiry (reset for passive windows, TTL fallback without a reset).
102
+
103
+ ## Channel Git credentials
104
+
105
+ See [Agent Git credentials](docs/git-credentials.md) for multi-host channel bindings,
106
+ provider CLI environment variables, helper matching and session file lifetime.
@@ -16,15 +16,20 @@ if (process.argv[2] === 'get') {
16
16
  if (separator > 0) request[line.slice(0, separator)] = line.slice(separator + 1)
17
17
  }
18
18
  const file = process.env.TV_GIT_TOKEN_FILE
19
- const credential = JSON.parse(readFileSync(file, 'utf8'))
20
- if (request.protocol !== 'https' || request.host !== credential.host ||
21
- credential.host !== 'github.com' || typeof credential.token !== 'string' ||
19
+ const stored = JSON.parse(readFileSync(file, 'utf8'))
20
+ const bindings = Array.isArray(stored) ? stored : [stored]
21
+ if (request.protocol !== 'https' || typeof request.host !== 'string' || request.host !== request.host.toLowerCase()) process.exit(0)
22
+ const credential = bindings.find(binding => binding?.host === request.host)
23
+ if (!credential ||
24
+ typeof credential.token !== 'string' ||
22
25
  !credential.token || /[\r\n\0]/.test(credential.token) ||
23
- !['github_token', 'github_app'].includes(credential.kind)) process.exit(0)
26
+ !['github_token', 'github_app', 'gitlab_token'].includes(credential.kind)) process.exit(0)
27
+ const username = credential.username ?? (credential.kind === 'gitlab_token' ? 'oauth2' : 'x-access-token')
28
+ if (!['x-access-token', 'oauth2'].includes(username)) process.exit(0)
24
29
  if (credential.expiresAt !== undefined) {
25
30
  const expiry = Date.parse(credential.expiresAt)
26
31
  if (!Number.isFinite(expiry) || expiry <= Date.now()) {
27
- const diagnostic = `git_credential_expired kind=${credential.kind}\n`
32
+ const diagnostic = `git_credential_expired kind=${credential.kind}${Array.isArray(stored) ? ` host=${credential.host}` : ''}\n`
28
33
  process.stderr.write(diagnostic)
29
34
  // Value-free spool lets the poller report detached-task expiry through
30
35
  // its usual error queue even after the launching agent has exited.
@@ -32,7 +37,7 @@ if (process.argv[2] === 'get') {
32
37
  process.exit(0)
33
38
  }
34
39
  }
35
- process.stdout.write(`username=x-access-token\npassword=${credential.token}\n`)
40
+ process.stdout.write(`username=${username}\npassword=${credential.token}\n`)
36
41
  } catch {
37
42
  // No output and success means Git proceeds to its prompt-disabled failure.
38
43
  }
@@ -29,7 +29,7 @@ if (config.PERSISTENT_STORAGE_PATH) {
29
29
  }
30
30
  }
31
31
  const gitCredentialContext = new AsyncLocalStorage();
32
- const githubBrains = new Set();
32
+ const httpsBrains = new Set();
33
33
  async function gitExec(command, options) {
34
34
  const context = gitCredentialContext.getStore();
35
35
  const run = async (credential) => {
@@ -86,7 +86,7 @@ async function gitExec(command, options) {
86
86
  }
87
87
  }
88
88
  async function withGitCredential(brainId, workspaceId, fn) {
89
- if (!workspaceId || !githubBrains.has(brainId) || !config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN)
89
+ if (!workspaceId || !httpsBrains.has(brainId) || !config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN)
90
90
  return fn();
91
91
  const credential = await connectionsClient.get('brain', brainId);
92
92
  return gitCredentialContext.run({ brainId, credential }, fn);
@@ -253,8 +253,8 @@ export async function getBrainPath(brain, channelId, workspaceId) {
253
253
  return config.DEFAULT_BRAIN_PATH;
254
254
  }
255
255
  const brainDir = resolveBrainDir(workspaceId, brain.brainId);
256
- if (/^https:\/\/github\.com\//i.test(brain.gitRepoUrl))
257
- githubBrains.add(brain.brainId);
256
+ if (/^https:\/\//i.test(brain.gitRepoUrl))
257
+ httpsBrains.add(brain.brainId);
258
258
  return withBrainLock(brainLockKey(brainDir, brain.brainId, workspaceId), () => withGitCredential(brain.brainId, workspaceId, () => getWorkspaceBrainPath(brain, workspaceId)));
259
259
  }
260
260
  async function getWorkspaceBrainPath(brain, workspaceId) {
@@ -9,7 +9,7 @@ import { type ClaudeRateLimitInfo } from './claude-rate-limit.js';
9
9
  import { type ClaudeQuotaFailure } from './claude-quota.js';
10
10
  import { type PoolRuntimeOptions, type PoolAttempt } from './subscription-runtime.js';
11
11
  import type { SubscriptionAccount } from './subscription-pool-client.js';
12
- import { type GitCredential } from './connections-client.js';
12
+ import { type GitCredential, type GitCredentialSet } from './connections-client.js';
13
13
  export declare function progressNotificationMarks(estimateMinutes: number): number[];
14
14
  export declare function computeCompactionKillDeadline(previousActivityTime: number, now: number, idleKillMs: number, graceMs: number): number;
15
15
  export interface ContextHintParams {
@@ -28,6 +28,8 @@ export interface SpawnResult {
28
28
  poolAttempts?: readonly PoolAttempt[];
29
29
  quota?: ClaudeQuotaFailure;
30
30
  rateLimitInfo?: ClaudeRateLimitInfo;
31
+ /** Rejections anywhere in this attempt survive a later allowed stream event. */
32
+ rateLimitRejections?: readonly ClaudeRateLimitInfo[];
31
33
  /** Actual ID passed to this CLI attempt, including a failed first turn. */
32
34
  attemptedSessionId?: string;
33
35
  success: boolean;
@@ -177,7 +179,7 @@ export interface BuildSpawnEnvParams {
177
179
  inboxThreadId: string;
178
180
  brainPath: string;
179
181
  subscriptionAccount?: SubscriptionAccount;
180
- gitCredential?: GitCredential;
182
+ gitCredential?: GitCredential | GitCredentialSet;
181
183
  gitTokenFile?: string;
182
184
  }
183
185
  /**
@@ -1,3 +1,4 @@
1
+ import { END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL } from './end-of-turn-sentinel.js';
1
2
  import { createSessionInstance, writeSessionOwner } from './session-instance.js';
2
3
  import { buildGitCredentialEnv } from './git-credential-env.js';
3
4
  import { createGitCredentialSession, finishGitCredentialSession, setGitCredentialSessionPid } from './git-credential-session.js';
@@ -20,7 +21,7 @@ import { fetchSecretsForSpawn, buildSecretsReminder } from './secrets-client.js'
20
21
  import { fetchConfigForSpawn } from './config-client.js';
21
22
  import { parseClaudeRateLimitInfo } from './claude-rate-limit.js';
22
23
  import { parseClaudeQuotaFailure } from './claude-quota.js';
23
- import { abortLocalProbe, accountPin, preparePool, fetchRuntimePool, validateAccount, poolDeferredError, PoolDeferredError } from './subscription-runtime.js';
24
+ import { abortLocalProbe, accountPin, preparePool, fetchRuntimePool, validateAccount, subscriptionUsageStore, poolDeferredError, PoolDeferredError } from './subscription-runtime.js';
24
25
  import { selectPoolForResolvedModel } from './subscription-pool.js';
25
26
  import { isQuotaPaused, QuotaPausedError } from './quota-pause.js';
26
27
  import { redactStartupText } from './startup-auth.js';
@@ -794,10 +795,10 @@ function resolveTypingStatusDetail(event, assistantText) {
794
795
  if (assistantCalledTool(event, 'mcp__slack__send_message'))
795
796
  return null;
796
797
  const text = assistantText ?? extractAssistantText(event);
797
- if (text) {
798
- // Collapse newlines/whitespace runs so a multi-line narration doesn't
799
- // land a literal `\n` in the one-line status surface.
800
- return { text: oneLineStatus(text), source: 'comment' };
798
+ // tv#589: the silence marker is control text, including inside narration.
799
+ const narration = text?.split(END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL).join('').replace(/\s+/g, ' ').trim();
800
+ if (narration) {
801
+ return { text: oneLineStatus(narration), source: 'comment' };
801
802
  }
802
803
  for (const block of event.message.content) {
803
804
  if (block?.type !== 'tool_use' || typeof block.name !== 'string')
@@ -809,6 +810,9 @@ function resolveTypingStatusDetail(event, assistantText) {
809
810
  return { text: oneLineStatus(status), source: `tool:${safeToolName}` };
810
811
  }
811
812
  }
813
+ // A sentinel-only event keeps the current status when no tool status applies.
814
+ if (text?.includes(END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL))
815
+ return null;
812
816
  return { text: 'Thinking...', source: 'default' };
813
817
  }
814
818
  export function resolveTypingStatus(event, assistantText) {
@@ -911,6 +915,14 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
911
915
  });
912
916
  if (runtime?.account)
913
917
  await validateAccount(runtime.account, runtime.resolved);
918
+ if (runtime?.account && !runtime.shouldStop()) {
919
+ const store = subscriptionUsageStore();
920
+ const attemptAt = Date.now();
921
+ const windows = await store.beginVerification(runtime.account, runtime.resolved.model, attemptAt);
922
+ if (windows === undefined)
923
+ throw await poolDeferredError('usage', { kind: 'ready', accounts: [runtime.account] }, runtime.resolved, store);
924
+ runtime.verificationAttemptAt = windows.length ? attemptAt : undefined;
925
+ }
914
926
  return new Promise((resolve) => {
915
927
  // Preparation above awaits remote config/context. Another thread may
916
928
  // have exhausted the shared account meanwhile. No awaits before spawn.
@@ -1083,6 +1095,16 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
1083
1095
  // Retain CLI error/result evidence, not the full parsed assistant/tool stream.
1084
1096
  const failureEvents = [];
1085
1097
  let rateLimitInfo;
1098
+ const rateLimitRejections = new Map();
1099
+ const captureRateLimit = (value) => {
1100
+ rateLimitInfo = parseClaudeRateLimitInfo(value);
1101
+ if (!runtime?.account || rateLimitInfo?.status !== 'rejected')
1102
+ return;
1103
+ for (const w of normalizePassiveUsage(rateLimitInfo, Date.now()).windows) {
1104
+ if (w.status === 'rejected')
1105
+ rateLimitRejections.set(`${w.window}:${w.scope}`, rateLimitInfo);
1106
+ }
1107
+ };
1086
1108
  let metrics;
1087
1109
  let lastAssistant;
1088
1110
  // teamvibe.ai#284 (poller-brain#235): end-of-turn guard state — tracked
@@ -1143,7 +1165,7 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
1143
1165
  // (including metadata keys) to logs, telemetry, typing, or Slack.
1144
1166
  const event = safeEvent(rawEvent);
1145
1167
  if (rawEvent?.type === 'rate_limit_event') {
1146
- rateLimitInfo = parseClaudeRateLimitInfo(event.rate_limit_info);
1168
+ captureRateLimit(event.rate_limit_info);
1147
1169
  }
1148
1170
  if (rawEvent?.type === 'result' || rawEvent?.type === 'error' || (rawEvent?.type === 'assistant' && rawEvent.error)) {
1149
1171
  failureEvents.push(rawEvent);
@@ -1367,7 +1389,7 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
1367
1389
  const rawEvent = JSON.parse(stdoutBuffer);
1368
1390
  const event = safeEvent(rawEvent);
1369
1391
  if (event?.type === 'rate_limit_event') {
1370
- rateLimitInfo = parseClaudeRateLimitInfo(event.rate_limit_info);
1392
+ captureRateLimit(event.rate_limit_info);
1371
1393
  }
1372
1394
  failureEvents.push(rawEvent);
1373
1395
  if (event?.type === 'assistant') {
@@ -1386,6 +1408,7 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
1386
1408
  attemptedSessionId: sessionId,
1387
1409
  ...(quota ? { quota } : {}),
1388
1410
  ...(rateLimitInfo ? { rateLimitInfo } : {}),
1411
+ ...(rateLimitRejections.size ? { rateLimitRejections: [...rateLimitRejections.values()] } : {}),
1389
1412
  output: redactOutput(stdout),
1390
1413
  exitCode: code,
1391
1414
  metrics,
@@ -1610,7 +1633,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1610
1633
  const modelNormalizedFrom = scoped?.model === resolved.model && scoped?.modelSource === resolved.modelSource
1611
1634
  ? scoped?.modelNormalizedFrom : undefined;
1612
1635
  const runtime = poolRuntime ? {
1613
- resolved, modelNormalizedFrom, claudeConfigDir, account: prepared?.account,
1636
+ resolved, modelNormalizedFrom, claudeConfigDir, account: prepared?.account, verificationAttemptAt: undefined,
1614
1637
  shouldStop: poolRuntime.shouldStop,
1615
1638
  resumePending: fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime.pin?.resumePending === true,
1616
1639
  onSpawn: () => {
@@ -1649,10 +1672,12 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1649
1672
  if (!runtime?.resumePending)
1650
1673
  sawSendMessageCall = false;
1651
1674
  const spawned = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime, gitDelivery);
1675
+ const rejected = spawned.rateLimitRejections?.find(rejectedForModel);
1652
1676
  const result = prepared?.account
1653
- ? { ...spawned, quota: quotaForModel(spawned.quota), success: rejectedForModel(spawned.rateLimitInfo) ? false : spawned.success } : spawned;
1677
+ ? { ...spawned, ...(rejected ? { rateLimitInfo: rejected } : {}), quota: quotaForModel(spawned.quota), success: rejected || rejectedForModel(spawned.rateLimitInfo) ? false : spawned.success } : spawned;
1654
1678
  sawSendMessageCall ||= result.sawSendMessageCall === true;
1655
1679
  if (prepared?.account) {
1680
+ const observedAt = Date.now();
1656
1681
  if (poolAttempts.length >= 1000)
1657
1682
  poolAttempts.shift();
1658
1683
  const selection = prepared.selection;
@@ -1664,7 +1689,8 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1664
1689
  accountId: prepared.account.accountId,
1665
1690
  credentialVersion: prepared.account.credentialVersion,
1666
1691
  accountLabel: prepared.account.label ?? prepared.account.accountId,
1667
- observedAt: Date.now(),
1692
+ observedAt,
1693
+ verificationAttemptAt: runtime?.verificationAttemptAt,
1668
1694
  rateLimitInfo: result.rateLimitInfo,
1669
1695
  sessionId: id,
1670
1696
  model: resolved.model ?? result.metrics?.model,
@@ -1675,13 +1701,19 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1675
1701
  metrics: result.metrics,
1676
1702
  });
1677
1703
  emitEvent('pool.account_attempted', msg.teamvibe.channelId, { workspaceId: msg.teamvibe.workspaceId, threadId: msg.thread_id, ...poolRunTelemetry(poolAttempts, poolRuntime?.quotaDeferEpisodes) });
1704
+ // Retain every rejected scope before completion can clear a verified
1705
+ // hold or return early. A failed write must not skip another scope.
1706
+ const rejectionOutcomes = await Promise.allSettled((result.rateLimitRejections ?? []).map(info => logPoolUsageWrite(sessionLog, prepared.account, info, () => prepared.store.recordPassive(prepared.account, info, observedAt, { expireFutureObservations: true }))));
1678
1707
  if (result.success && id && poolRuntime) {
1679
- // Complete the inbox before any fallible post-inference store writes.
1680
- // The loop retains bookkeeping independently of the original prompt.
1708
+ // Complete the inbox even if rejection bookkeeping failed. The loop
1709
+ // retains completion independently of the original prompt.
1681
1710
  const bookkeepingComplete = await poolRuntime.complete(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: false }, poolAttempts, result.rateLimitInfo);
1682
1711
  if (bookkeepingComplete !== false)
1683
1712
  await emitCandidateUsage(msg, [prepared.account], prepared.store);
1684
1713
  retainWorktree = false;
1714
+ const failed = rejectionOutcomes.find((outcome) => outcome.status === 'rejected');
1715
+ if (failed)
1716
+ throw new PoolDeferredError('infrastructure', { error: failed.reason });
1685
1717
  return result;
1686
1718
  }
1687
1719
  if ((result.quota || rejectedForModel(result.rateLimitInfo)) && !result.killedByPoller) {
@@ -1692,15 +1724,17 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1692
1724
  // retaining the attempt. Selection waits for all writes before switching.
1693
1725
  const writes = [];
1694
1726
  if (result.quota && !result.killedByPoller)
1695
- writes.push(logPoolUsageWrite(sessionLog, prepared.account, result.quota.window ? { rateLimitType: result.quota.window } : undefined, () => prepared.store.recordQuota(prepared.account, result.quota, Date.now(), { expireFutureObservations: true }), 'quota'));
1696
- if (result.rateLimitInfo)
1697
- writes.push(logPoolUsageWrite(sessionLog, prepared.account, result.rateLimitInfo, () => prepared.store.recordPassive(prepared.account, result.rateLimitInfo, Date.now(), { expireFutureObservations: true })));
1727
+ writes.push(logPoolUsageWrite(sessionLog, prepared.account, result.quota.window ? { rateLimitType: result.quota.window } : undefined, () => prepared.store.recordQuota(prepared.account, result.quota, observedAt, { expireFutureObservations: true }), 'quota'));
1728
+ if (!result.rateLimitRejections?.length && result.rateLimitInfo)
1729
+ writes.push(logPoolUsageWrite(sessionLog, prepared.account, result.rateLimitInfo, () => prepared.store.recordPassive(prepared.account, result.rateLimitInfo, observedAt, { expireFutureObservations: true })));
1698
1730
  if (id && poolRuntime)
1699
1731
  writes.push(poolRuntime.persist(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: runtime?.resumePending, sawSendMessageCall }, poolAttempts));
1700
- const outcomes = await Promise.allSettled(writes);
1732
+ const outcomes = [...rejectionOutcomes, ...await Promise.allSettled(writes)];
1701
1733
  const failed = outcomes.find((outcome) => outcome.status === 'rejected');
1702
1734
  if (failed)
1703
1735
  throw new PoolDeferredError('infrastructure', { error: failed.reason });
1736
+ if (runtime?.verificationAttemptAt !== undefined && !result.killedByPoller && result.exitCode !== null)
1737
+ await prepared.store.completeVerification(prepared.account, runtime.verificationAttemptAt, observedAt);
1704
1738
  }
1705
1739
  return result;
1706
1740
  };
package/dist/config.d.ts CHANGED
@@ -19,6 +19,7 @@ declare const configSchema: z.ZodObject<{
19
19
  NETWORK_ERROR_KILL_MS: z.ZodDefault<z.ZodNumber>;
20
20
  MAX_CONSECUTIVE_NET_FAILURES: z.ZodDefault<z.ZodNumber>;
21
21
  TYPING_COMMENT_MIN_HOLD_MS: z.ZodDefault<z.ZodNumber>;
22
+ TYPING_STATUS_REQUEST_TIMEOUT_MS: z.ZodDefault<z.ZodNumber>;
22
23
  TEAMVIBE_DATA_DIR: z.ZodDefault<z.ZodString>;
23
24
  BRAINS_PATH: z.ZodDefault<z.ZodString>;
24
25
  DEFAULT_BRAIN_PATH: z.ZodDefault<z.ZodString>;
@@ -63,6 +64,7 @@ declare const configSchema: z.ZodObject<{
63
64
  NETWORK_ERROR_KILL_MS: number;
64
65
  MAX_CONSECUTIVE_NET_FAILURES: number;
65
66
  TYPING_COMMENT_MIN_HOLD_MS: number;
67
+ TYPING_STATUS_REQUEST_TIMEOUT_MS: number;
66
68
  TEAMVIBE_DATA_DIR: string;
67
69
  BRAINS_PATH: string;
68
70
  DEFAULT_BRAIN_PATH: string;
@@ -113,6 +115,7 @@ declare const configSchema: z.ZodObject<{
113
115
  NETWORK_ERROR_KILL_MS?: number | undefined;
114
116
  MAX_CONSECUTIVE_NET_FAILURES?: number | undefined;
115
117
  TYPING_COMMENT_MIN_HOLD_MS?: number | undefined;
118
+ TYPING_STATUS_REQUEST_TIMEOUT_MS?: number | undefined;
116
119
  TEAMVIBE_DATA_DIR?: string | undefined;
117
120
  BRAINS_PATH?: string | undefined;
118
121
  DEFAULT_BRAIN_PATH?: string | undefined;
@@ -160,6 +163,7 @@ export declare const config: {
160
163
  NETWORK_ERROR_KILL_MS: number;
161
164
  MAX_CONSECUTIVE_NET_FAILURES: number;
162
165
  TYPING_COMMENT_MIN_HOLD_MS: number;
166
+ TYPING_STATUS_REQUEST_TIMEOUT_MS: number;
163
167
  TEAMVIBE_DATA_DIR: string;
164
168
  BRAINS_PATH: string;
165
169
  DEFAULT_BRAIN_PATH: string;
package/dist/config.js CHANGED
@@ -28,6 +28,7 @@ const configSchema = z.object({
28
28
  NETWORK_ERROR_KILL_MS: z.coerce.number().default(120000), // Per-session: kill stuck session after 2 min of network errors
29
29
  MAX_CONSECUTIVE_NET_FAILURES: z.coerce.number().default(5), // Poll loop: DNS flush at N, exit(1) at 2×N consecutive failures
30
30
  TYPING_COMMENT_MIN_HOLD_MS: z.coerce.number().default(4500), // Minimum time to display agent narration before a canned status
31
+ TYPING_STATUS_REQUEST_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000), // Abort stalled Slack status requests
31
32
  // Paths
32
33
  TEAMVIBE_DATA_DIR: z.string().default(DEFAULT_DATA_DIR),
33
34
  BRAINS_PATH: z.string().default(''),
@@ -1,13 +1,24 @@
1
+ export type GitCredentialKind = 'github_token' | 'github_app' | 'gitlab_token';
1
2
  export type GitCredential = {
2
3
  source: 'platform';
3
- host: 'github.com';
4
- username: 'x-access-token';
4
+ host: string;
5
+ username: 'x-access-token' | 'oauth2';
5
6
  token: string;
6
7
  connectionId: string;
7
8
  credentialVersion: string;
8
9
  bindingVersion: number;
9
10
  expiresAt?: string;
10
- kind?: 'github_token' | 'github_app';
11
+ kind: GitCredentialKind;
12
+ } | {
13
+ source: 'none';
14
+ legacyAllowed?: boolean;
15
+ };
16
+ export type PlatformCredential = Omit<Extract<GitCredential, {
17
+ source: 'platform';
18
+ }>, 'source'>;
19
+ export type GitCredentialSet = {
20
+ source: 'platform';
21
+ bindings: PlatformCredential[];
11
22
  } | {
12
23
  source: 'none';
13
24
  legacyAllowed?: boolean;
@@ -59,7 +70,8 @@ export declare class ConnectionsClient {
59
70
  recordBrainSource(brainId: string, source: 'platform' | 'legacy'): void;
60
71
  get legacyGitBrains(): number;
61
72
  private noticeLegacyServer;
62
- get(scope: Scope, id: string, force?: boolean): Promise<GitCredential>;
73
+ get(scope: 'brain', id: string, force?: boolean): Promise<GitCredential>;
74
+ get(scope: Scope, id: string, force?: boolean): Promise<GitCredential | GitCredentialSet>;
63
75
  }
64
76
  export declare const connectionsClient: ConnectionsClient;
65
77
  export declare function refreshConnectionTuples(pollerId: string): Promise<void>;
@@ -1,3 +1,4 @@
1
+ import { gitCredentialBindings } from './git-credential-bindings.js';
1
2
  import { config } from './config.js';
2
3
  import { registerConfiguredToken } from './redact-text.js';
3
4
  export class ConnectionFetchError extends Error {
@@ -9,6 +10,22 @@ export class ConnectionFetchError extends Error {
9
10
  this.status = status;
10
11
  }
11
12
  }
13
+ // RFC 1123 hostname: lowercase labels of 1–63 characters, 253 total,
14
+ // with no scheme, port, path, trailing dot or leading/trailing hyphens.
15
+ function isCredentialHost(host) {
16
+ return typeof host === 'string' && host.length <= 253 && !/[^a-z0-9.-]/.test(host) &&
17
+ host.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
18
+ }
19
+ function isPlatformCredential(value) {
20
+ if (!value || typeof value !== 'object')
21
+ return false;
22
+ const body = value;
23
+ return isCredentialHost(body.host) &&
24
+ ((body.kind === 'github_token' || body.kind === 'github_app') && body.host === 'github.com' && body.username === 'x-access-token' ||
25
+ body.kind === 'gitlab_token' && body.host !== 'github.com' && body.username === 'oauth2') &&
26
+ typeof body.token === 'string' && !!body.token && typeof body.connectionId === 'string' &&
27
+ typeof body.credentialVersion === 'string' && typeof body.bindingVersion === 'number';
28
+ }
12
29
  /** Cache keys include consumer scope. Binding tuples arrive at startup and
13
30
  * each brains refresh; a changed tuple evicts immediately. */
14
31
  export class ConnectionsClient {
@@ -75,8 +92,10 @@ export class ConnectionsClient {
75
92
  if (!/^[A-Za-z0-9_-]{1,128}$/.test(id))
76
93
  throw new ConnectionFetchError('invalid_request', 400);
77
94
  const url = new URL(scope === 'brain' ? `/brains/${encodeURIComponent(id)}/git-credential` : '/connections/git/spawn', this.options.apiUrl);
78
- if (scope === 'channel')
95
+ if (scope === 'channel') {
79
96
  url.searchParams.set('channelId', id);
97
+ url.searchParams.set('format', 'bindings');
98
+ }
80
99
  const response = await (this.options.fetch ?? fetch)(url, { headers: { Authorization: `Bearer ${this.options.pollerToken}`, 'Cache-Control': 'no-store' }, redirect: 'error' });
81
100
  if (!response.ok) {
82
101
  if (response.status === 405 || response.status === 404 && !(await response.clone().json().catch(() => ({}))).code) {
@@ -87,20 +106,32 @@ export class ConnectionsClient {
87
106
  const code = { 400: 'invalid_request', 401: 'unauthorized', 403: 'forbidden', 404: 'connection_unbound', 409: 'connection_disabled', 410: 'connection_deleted' }[response.status] ?? 'credential_store_unavailable';
88
107
  throw new ConnectionFetchError(code, response.status);
89
108
  }
90
- const body = await response.json();
109
+ const body = await response.json().catch(() => null);
91
110
  let value;
92
- if (body.source === 'none')
93
- value = { source: 'none', ...(body.legacyAllowed === true ? { legacyAllowed: true } : {}) };
94
- else if (body.source === 'platform' && body.host === 'github.com' && body.username === 'x-access-token' &&
95
- typeof body.token === 'string' && body.token && typeof body.connectionId === 'string' &&
96
- typeof body.credentialVersion === 'string' && typeof body.bindingVersion === 'number') {
97
- value = body;
98
- registerConfiguredToken(body.token);
111
+ if (body?.['source'] === 'none')
112
+ value = { source: 'none', ...(body['legacyAllowed'] === true ? { legacyAllowed: true } : {}) };
113
+ else if (body?.['source'] === 'platform') {
114
+ if (scope === 'channel') {
115
+ // Older servers may ignore format=bindings; tolerate their single body.
116
+ const { source: _, ...single } = body;
117
+ const bindings = Object.hasOwn(body, 'bindings') ? body['bindings'] : [single];
118
+ if (!Array.isArray(bindings) || bindings.length === 0 || !bindings.every(isPlatformCredential) ||
119
+ new Set(bindings.map(binding => binding.host.toLowerCase())).size !== bindings.length)
120
+ throw new ConnectionFetchError('invalid_response', 503);
121
+ value = { source: 'platform', bindings };
122
+ }
123
+ else if (isPlatformCredential(body))
124
+ value = body;
125
+ else
126
+ throw new ConnectionFetchError('invalid_response', 503);
127
+ for (const binding of gitCredentialBindings(value))
128
+ registerConfiguredToken(binding.token);
99
129
  }
100
130
  else
101
131
  throw new ConnectionFetchError('invalid_response', 503);
102
- const validUntil = value.source === 'platform' && value.expiresAt
103
- ? Date.parse(value.expiresAt) - 5 * 60_000 : this.now() + 10 * 60_000;
132
+ const bindings = gitCredentialBindings(value);
133
+ const validUntil = bindings.length ? Math.min(...bindings.map(binding => binding.expiresAt
134
+ ? Date.parse(binding.expiresAt) - 5 * 60_000 : this.now() + 10 * 60_000)) : this.now() + 10 * 60_000;
104
135
  if (validUntil > this.now())
105
136
  this.entries.set(key, { value, tuple: tuple ?? '', validUntil });
106
137
  return value;
@@ -0,0 +1,2 @@
1
+ /** Claude's end-of-turn marker when no further visible reply is needed. */
2
+ export declare const END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL = "NO_FURTHER_RESPONSE_NEEDED";
@@ -0,0 +1,2 @@
1
+ /** Claude's end-of-turn marker when no further visible reply is needed. */
2
+ export const END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL = 'NO_FURTHER_RESPONSE_NEEDED';
@@ -0,0 +1,2 @@
1
+ import type { GitCredential, GitCredentialSet, PlatformCredential } from './connections-client.js';
2
+ export declare function gitCredentialBindings(credential: GitCredential | GitCredentialSet): PlatformCredential[];
@@ -0,0 +1,4 @@
1
+ // Shared single-consumer adapter; preserves the brain credential contract.
2
+ export function gitCredentialBindings(credential) {
3
+ return credential.source === 'none' ? [] : 'bindings' in credential ? credential.bindings : [credential];
4
+ }
@@ -1,3 +1,3 @@
1
- import type { GitCredential } from './connections-client.js';
1
+ import type { GitCredential, GitCredentialSet } from './connections-client.js';
2
2
  export declare const GIT_CREDENTIAL_HELPER: string;
3
- export declare function buildGitCredentialEnv(base: NodeJS.ProcessEnv, credential: GitCredential, tokenFile?: string): NodeJS.ProcessEnv;
3
+ export declare function buildGitCredentialEnv(base: NodeJS.ProcessEnv, credential: GitCredential | GitCredentialSet, tokenFile?: string): NodeJS.ProcessEnv;
@@ -1,3 +1,4 @@
1
+ import { gitCredentialBindings } from './git-credential-bindings.js';
1
2
  import { fileURLToPath } from 'node:url';
2
3
  import { registerConfiguredToken } from './redact-text.js';
3
4
  // Works from both src/ (tests) and dist/ (published package).
@@ -9,29 +10,41 @@ export function buildGitCredentialEnv(base, credential, tokenFile) {
9
10
  // Do not inherit a prior session's helper/file, even on eligible legacy.
10
11
  if (key.startsWith('TV_GIT_') || !legacy && (/^GIT_(?:CONFIG|CREDENTIAL|TRACE|SSL|HTTP|SSH)(?:_|$)/.test(key) ||
11
12
  ['GIT_ASKPASS', 'GIT_EXEC_PATH', 'GIT_PROXY_COMMAND', 'GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE', 'GIT_NAMESPACE', 'GIT_TEMPLATE_DIR', 'GIT_PROTOCOL', 'GIT_ALLOW_PROTOCOL', 'SSH_ASKPASS'].includes(key) ||
12
- ['GITHUB_TOKEN', 'GH_TOKEN', 'GITLAB_TOKEN'].includes(key)))
13
+ ['GITHUB_TOKEN', 'GH_TOKEN', 'GITLAB_TOKEN', 'GITLAB_HOST'].includes(key)))
13
14
  delete env[key];
14
15
  }
15
16
  if (legacy)
16
17
  return env;
17
18
  env['GIT_TERMINAL_PROMPT'] = '0';
18
- env['GIT_CONFIG_COUNT'] = credential.source === 'platform' ? '3' : '1';
19
+ const bindings = gitCredentialBindings(credential);
20
+ env['GIT_CONFIG_COUNT'] = String(1 + 2 * bindings.length);
19
21
  env['GIT_CONFIG_KEY_0'] = 'credential.helper';
20
22
  env['GIT_CONFIG_VALUE_0'] = '';
21
23
  if (credential.source === 'platform') {
22
24
  if (!tokenFile)
23
25
  throw new Error('Git credential token file required');
24
- registerConfiguredToken(credential.token);
25
26
  env['TV_GIT_TOKEN_FILE'] = tokenFile;
26
- env['GIT_CONFIG_KEY_1'] = `credential.https://${credential.host}.helper`;
27
27
  // Strip CLI snapshots before the helper process starts. Git appends only
28
28
  // get/store/erase; the token remains exclusively in the private file.
29
29
  const helperPath = "'" + GIT_CREDENTIAL_HELPER.replace(/'/g, "'\\''") + "'";
30
- env['GIT_CONFIG_VALUE_1'] = `!env -u GITHUB_TOKEN -u GH_TOKEN -u GITLAB_TOKEN ${helperPath}`;
31
- env['GIT_CONFIG_KEY_2'] = `credential.https://${credential.host}.useHttpPath`;
32
- env['GIT_CONFIG_VALUE_2'] = 'true';
33
- env['GH_TOKEN'] = credential.token;
34
- env['GITHUB_TOKEN'] = credential.token;
30
+ for (const [index, binding] of bindings.entries()) {
31
+ registerConfiguredToken(binding.token);
32
+ const key = 1 + 2 * index;
33
+ env[`GIT_CONFIG_KEY_${key}`] = `credential.https://${binding.host}.helper`;
34
+ env[`GIT_CONFIG_VALUE_${key}`] = `!env -u GITHUB_TOKEN -u GH_TOKEN -u GITLAB_TOKEN ${helperPath}`;
35
+ env[`GIT_CONFIG_KEY_${key + 1}`] = `credential.https://${binding.host}.useHttpPath`;
36
+ env[`GIT_CONFIG_VALUE_${key + 1}`] = 'true';
37
+ if (binding.host === 'github.com') {
38
+ env['GH_TOKEN'] = binding.token;
39
+ env['GITHUB_TOKEN'] = binding.token;
40
+ }
41
+ else if (binding.kind === 'gitlab_token' && env['GITLAB_TOKEN'] === undefined) {
42
+ // glab/API env covers the first GitLab host in canonical order;
43
+ // Git's host-scoped helpers cover every binding.
44
+ env['GITLAB_TOKEN'] = binding.token;
45
+ env['GITLAB_HOST'] = binding.host;
46
+ }
47
+ }
35
48
  }
36
49
  return env;
37
50
  }
@@ -1,6 +1,6 @@
1
- import type { GitCredential } from './connections-client.js';
1
+ import type { GitCredential, GitCredentialKind, GitCredentialSet, PlatformCredential } from './connections-client.js';
2
2
  import { type SessionInstance } from './session-instance.js';
3
- type PlatformCredential = Extract<GitCredential, {
3
+ type PlatformDelivery = Extract<GitCredential | GitCredentialSet, {
4
4
  source: 'platform';
5
5
  }>;
6
6
  export interface GitCredentialSession extends SessionInstance {
@@ -10,13 +10,13 @@ export interface GitCredentialSession extends SessionInstance {
10
10
  }
11
11
  export interface AgentGitDelivery {
12
12
  instance?: SessionInstance;
13
- credential?: GitCredential;
13
+ credential?: GitCredential | GitCredentialSet;
14
14
  session?: GitCredentialSession;
15
15
  }
16
16
  export declare function gitCredentialRoot(): string;
17
- export declare function gitCredentialKind(credential: PlatformCredential): 'github_app' | 'github_token';
18
- export declare function writeGitTokenFile(dir: string, credential: PlatformCredential): void;
19
- export declare function createGitCredentialSession(credential: PlatformCredential, options: {
17
+ export declare function gitCredentialKind(credential: PlatformCredential): GitCredentialKind;
18
+ export declare function writeGitTokenFile(dir: string, credential: PlatformDelivery): void;
19
+ export declare function createGitCredentialSession(credential: PlatformDelivery, options: {
20
20
  root?: string;
21
21
  channelId: string;
22
22
  threadId: string;
@@ -1,3 +1,4 @@
1
+ import { gitCredentialBindings } from './git-credential-bindings.js';
1
2
  import { execFileSync } from 'node:child_process';
2
3
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
4
  import { randomUUID } from 'node:crypto';
@@ -47,8 +48,13 @@ export function gitCredentialKind(credential) {
47
48
  return credential.kind ?? (credential.expiresAt ? 'github_app' : 'github_token');
48
49
  }
49
50
  export function writeGitTokenFile(dir, credential) {
50
- registerConfiguredToken(credential.token);
51
- atomicWrite(join(dir, 'git-token'), JSON.stringify({ host: credential.host, token: credential.token, expiresAt: credential.expiresAt, kind: gitCredentialKind(credential) }));
51
+ const entries = gitCredentialBindings(credential).map(binding => {
52
+ registerConfiguredToken(binding.token);
53
+ return { host: binding.host, username: binding.username, token: binding.token, expiresAt: binding.expiresAt, kind: gitCredentialKind(binding) };
54
+ });
55
+ // Channel sets use an ordered array; single brain credentials keep their
56
+ // existing file shape. The helper reads either format on every invocation.
57
+ atomicWrite(join(dir, 'git-token'), JSON.stringify('bindings' in credential ? entries : entries[0]));
52
58
  }
53
59
  export function createGitCredentialSession(credential, options) {
54
60
  const root = options.root ?? gitCredentialRoot();
@@ -60,7 +66,9 @@ export function createGitCredentialSession(credential, options) {
60
66
  const instance = options.instance ?? createSessionInstance();
61
67
  writeSessionOwner(join(dir, 'owner.json'), instance);
62
68
  writeGitTokenFile(dir, credential);
63
- const metadata = { ...instance, ownerPid: process.pid, ownerIdentity, active: true, channelId: options.channelId, threadId: options.threadId, connectionId: credential.connectionId, kind: gitCredentialKind(credential) };
69
+ const metadata = { ...instance, ownerPid: process.pid, ownerIdentity, active: true, channelId: options.channelId, threadId: options.threadId, ...('bindings' in credential
70
+ ? { bindings: credential.bindings.map(binding => ({ connectionId: binding.connectionId, kind: gitCredentialKind(binding), host: binding.host })) }
71
+ : { connectionId: credential.connectionId, kind: gitCredentialKind(credential) }) };
64
72
  atomicWrite(join(dir, 'session.json'), JSON.stringify(metadata));
65
73
  const tasksFile = join(dir, 'bg-tasks');
66
74
  writeFileSync(tasksFile, '', { mode: 0o600, flag: 'wx' });
@@ -187,7 +195,19 @@ function sweepSession(dir) {
187
195
  reportSessionOwnerMismatch(expected, current);
188
196
  const events = join(dir, 'git-credential-events');
189
197
  if (metadata && matches && existsSync(events)) {
190
- if (!metadata.expiryReported) {
198
+ if (metadata.bindings) {
199
+ const diagnostics = new Set(readFileSync(events, 'utf8').split('\n'));
200
+ const expiredHosts = new Set(metadata.expiredHosts);
201
+ for (const binding of metadata.bindings) {
202
+ if (!expiredHosts.has(binding.host) && diagnostics.has(`git_credential_expired kind=${binding.kind} host=${binding.host}`)) {
203
+ emitError('session', `git_credential_expired kind=${binding.kind} host=${binding.host}`, { channelId: metadata.channelId, context: { code: 'git_credential_expired', ...binding, threadId: metadata.threadId } });
204
+ expiredHosts.add(binding.host);
205
+ }
206
+ }
207
+ metadata.expiredHosts = [...expiredHosts];
208
+ atomicWrite(path, JSON.stringify(metadata));
209
+ }
210
+ else if (!metadata.expiryReported) {
191
211
  emitError('session', `git_credential_expired kind=${metadata.kind}`, { channelId: metadata.channelId, context: { code: 'git_credential_expired', kind: metadata.kind, connectionId: metadata.connectionId, threadId: metadata.threadId } });
192
212
  metadata.expiryReported = true;
193
213
  atomicWrite(path, JSON.stringify(metadata));
@@ -27,8 +27,8 @@ export interface InboxWatcherDeps {
27
27
  * threads (see modules-teamvibe/hooks/slack-events.ts). Synthetic threadIds
28
28
  * used elsewhere (`maintenance_<brainId>_<ts>`, and
29
29
  * `scheduled:<scheduleId>:<ts>` from
30
- * modules-teamvibe/src/functions/scheduler.ts:86) never sit idle waiting on
31
- * a background-task drop, so they're skipped rather than guessed at.
30
+ * modules-teamvibe/src/functions/scheduler.ts) carry no Slack destination;
31
+ * scheduler recovery needs the poller-owned routing record exclusively.
32
32
  * `scheduled:*:*` has the same 3-colon-segment shape as a real Slack
33
33
  * threadId, so it's rejected explicitly rather than by segment count alone
34
34
  * — otherwise a leftover unprocessed file in a stale scheduled-session inbox