@teamvibe/poller 0.8.8 → 0.8.9-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.
package/README.md CHANGED
@@ -99,3 +99,43 @@ 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
+ ### Brain inventory event (#517)
104
+
105
+ The existing `/events` queue carries `brain.inventory` alongside session, pool,
106
+ maintenance and poller events. Collection runs after pull, before Claude starts,
107
+ for normal **and maintenance** sessions. Per workspace/brain, unchanged snapshots
108
+ are suppressed for 10 minutes; changes to inventory, HEAD, or poller version emit
109
+ immediately. Collection failures log a generic warning and never block the agent.
110
+
111
+ `metadata` is additive and versioned:
112
+
113
+ ```ts
114
+ {
115
+ schemaVersion: 1,
116
+ workspaceId, brainId, repo, commit, pollerVersion,
117
+ skills: [{ name, path, discovered, state, description?, source?, ref?, sha?, contentHash? }],
118
+ mcpServers: [{ name, type, requiredEnv: string[], missingEnv: string[] }],
119
+ locks: { skillsLockJson: boolean, skillsLockYaml: boolean },
120
+ inventoryHash
121
+ }
122
+ ```
123
+
124
+ Only flat `.claude/skills/<name>/SKILL.md` directories are discovered. Nested skills
125
+ are reported as `not-discovered`; missing or invalid YAML frontmatter is `invalid`.
126
+ A lock entry makes a valid flat skill `vendored`; other valid flat skills are `own`.
127
+ Both `skills-lock.json` and `.claude/skills/skills.lock.yaml` are optional; wrapper
128
+ YAML provenance takes precedence over JSON. `contentHash` hashes actual skill files.
129
+ MCP `${VAR}` and `${VAR:-default}` placeholders in `env` and `headers` produce names
130
+ only, compared with defined keys in the resolved spawn environment. No commands,
131
+ URLs, headers, literal credentials, or environment values from `.mcp.json` are sent.
132
+ Symlinked files/directories are not followed. Git repository URL credentials and
133
+ query strings are removed from event attribution.
134
+
135
+ See [#517](https://github.com/teamvibeai/teamvibe.ai/issues/517). Read-only v1;
136
+ updates/additions and scheduled skill maintenance actions are follow-up work.
137
+
138
+ ## Channel Git credentials
139
+
140
+ See [Agent Git credentials](docs/git-credentials.md) for multi-host channel bindings,
141
+ 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
  }
@@ -0,0 +1,13 @@
1
+ export declare function shouldEmitInventory(previous: {
2
+ hash: string;
3
+ at: number;
4
+ } | undefined, hash: string, now: number): boolean;
5
+ /** Best effort telemetry; a malformed brain or unavailable git must never block Claude. */
6
+ export declare function emitBrainInventory(params: {
7
+ brainPath: string;
8
+ brainId: string;
9
+ workspaceId: string;
10
+ channelId: string;
11
+ repo: string;
12
+ envNames: string[];
13
+ }): Promise<void>;
@@ -0,0 +1,57 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { collectBrainInventory } from './brain-inventory.js';
4
+ import { emitEvent } from './event-queue.js';
5
+ import { POLLER_VERSION } from './config.js';
6
+ import { logger } from './logger.js';
7
+ const exec = promisify(execFile);
8
+ const lastEmitted = new Map();
9
+ const INTERVAL_MS = 10 * 60_000;
10
+ export function shouldEmitInventory(previous, hash, now) {
11
+ return !previous || previous.hash !== hash || now - previous.at >= INTERVAL_MS;
12
+ }
13
+ /** Best effort telemetry; a malformed brain or unavailable git must never block Claude. */
14
+ export async function emitBrainInventory(params) {
15
+ try {
16
+ const inventory = await collectBrainInventory(params.brainPath, params.envNames);
17
+ const { stdout } = await exec('git', [
18
+ '-C',
19
+ params.brainPath,
20
+ 'rev-parse',
21
+ 'HEAD',
22
+ ]);
23
+ const commit = stdout.trim();
24
+ // Include HEAD in throttling so an unrelated commit is still reflected in attribution.
25
+ const hash = `${inventory.inventoryHash}:${commit}:${POLLER_VERSION}`;
26
+ const key = `${params.workspaceId}:${params.brainId}`;
27
+ const now = Date.now();
28
+ if (!shouldEmitInventory(lastEmitted.get(key), hash, now))
29
+ return;
30
+ let repo = params.repo;
31
+ try {
32
+ const url = new URL(repo);
33
+ url.username = '';
34
+ url.password = '';
35
+ url.search = '';
36
+ url.hash = '';
37
+ repo = url.toString();
38
+ }
39
+ catch {
40
+ /* SSH remotes carry no HTTP credentials. */
41
+ }
42
+ emitEvent('brain.inventory', params.channelId, {
43
+ schemaVersion: 1,
44
+ workspaceId: params.workspaceId,
45
+ brainId: params.brainId,
46
+ repo,
47
+ commit,
48
+ pollerVersion: POLLER_VERSION,
49
+ ...inventory,
50
+ });
51
+ lastEmitted.set(key, { hash, at: now });
52
+ }
53
+ catch {
54
+ // Do not echo parser exceptions: they can contain raw MCP credential values.
55
+ logger.warn('[BrainInventory] Could not collect inventory');
56
+ }
57
+ }
@@ -0,0 +1,28 @@
1
+ export interface InventorySkill {
2
+ name: string;
3
+ path: string;
4
+ discovered: boolean;
5
+ state: 'own' | 'vendored' | 'not-discovered' | 'invalid';
6
+ target?: string;
7
+ description?: string;
8
+ source?: string;
9
+ ref?: string;
10
+ sha?: string;
11
+ contentHash: string;
12
+ }
13
+ export interface InventoryMcpServer {
14
+ name: string;
15
+ type: 'stdio' | 'http';
16
+ requiredEnv: string[];
17
+ missingEnv: string[];
18
+ }
19
+ /** Read-only, deterministic collection. Receives env NAMES; never accepts env values. */
20
+ export declare function collectBrainInventory(brainPath: string, envNames: readonly string[]): Promise<{
21
+ inventoryHash: string;
22
+ skills: InventorySkill[];
23
+ mcpServers: InventoryMcpServer[];
24
+ locks: {
25
+ skillsLockJson: boolean;
26
+ skillsLockYaml: boolean;
27
+ };
28
+ }>;
@@ -0,0 +1,209 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readdir, readFile, lstat, realpath } from 'node:fs/promises';
3
+ import { join, posix, relative as relativePath, isAbsolute, sep, } from 'node:path';
4
+ import { parse } from 'yaml';
5
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
6
+ const object = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
7
+ async function optionalFile(path) {
8
+ try {
9
+ if (!(await lstat(path)).isFile())
10
+ return undefined; // Never follow symlinks out of the brain.
11
+ return await readFile(path, 'utf8');
12
+ }
13
+ catch (error) {
14
+ if (['ENOENT', 'ENOTDIR'].includes(error.code ?? ''))
15
+ return undefined;
16
+ throw error;
17
+ }
18
+ }
19
+ function document(text, yaml = false) {
20
+ try {
21
+ return object(text === undefined
22
+ ? undefined
23
+ : yaml
24
+ ? parse(text, { maxAliasCount: 0 })
25
+ : JSON.parse(text));
26
+ }
27
+ catch {
28
+ return {};
29
+ } // Bad optional locks/config must not prevent the remaining inventory.
30
+ }
31
+ async function entries(path) {
32
+ try {
33
+ return (await readdir(path, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, 'en'));
34
+ }
35
+ catch (error) {
36
+ if (['ENOENT', 'ENOTDIR'].includes(error.code ?? ''))
37
+ return [];
38
+ throw error;
39
+ }
40
+ }
41
+ async function directoryHash(path) {
42
+ const parts = [];
43
+ for (const entry of await entries(path)) {
44
+ if (['.git', 'node_modules'].includes(entry.name))
45
+ continue;
46
+ if (entry.isFile())
47
+ parts.push(`${entry.name}\0${hash(await readFile(join(path, entry.name)))}`);
48
+ if (entry.isDirectory())
49
+ parts.push(`${entry.name}/\0${await directoryHash(join(path, entry.name))}`);
50
+ }
51
+ return hash(parts.join('\n'));
52
+ }
53
+ /** Read-only, deterministic collection. Receives env NAMES; never accepts env values. */
54
+ export async function collectBrainInventory(brainPath, envNames) {
55
+ const brainRoot = await realpath(brainPath);
56
+ const skillsPath = join(brainRoot, '.claude/skills');
57
+ const [jsonText, yamlText, mcpText] = await Promise.all([
58
+ optionalFile(join(brainPath, 'skills-lock.json')),
59
+ optionalFile(join(skillsPath, 'skills.lock.yaml')),
60
+ optionalFile(join(brainPath, '.mcp.json')),
61
+ ]);
62
+ const jsonLocks = object(document(jsonText)['skills']);
63
+ const yamlLocks = object(document(yamlText, true)['skills']);
64
+ const skills = [];
65
+ async function walk(path, relative, depth, ancestors) {
66
+ for (const entry of await entries(path)) {
67
+ if ((!entry.isDirectory() && !entry.isSymbolicLink()) ||
68
+ ['.git', 'node_modules'].includes(entry.name))
69
+ continue;
70
+ let child = join(path, entry.name);
71
+ const skillPath = posix.join(relative, entry.name);
72
+ let target;
73
+ const invalidLink = () => skills.push({
74
+ name: entry.name,
75
+ path: skillPath,
76
+ discovered: false,
77
+ state: 'invalid',
78
+ contentHash: hash(''), // No target files are read for unsafe or dangling links.
79
+ });
80
+ if (entry.isSymbolicLink()) {
81
+ try {
82
+ child = await realpath(child);
83
+ const resolved = relativePath(brainRoot, child);
84
+ if (resolved === '..' ||
85
+ resolved.startsWith(`..${sep}`) ||
86
+ isAbsolute(resolved) ||
87
+ ancestors.has(child)) {
88
+ invalidLink();
89
+ continue;
90
+ }
91
+ if (!(await lstat(child)).isDirectory()) {
92
+ invalidLink();
93
+ continue;
94
+ }
95
+ target = resolved.split(sep).join('/');
96
+ }
97
+ catch (error) {
98
+ if (!['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code ?? ''))
99
+ throw error;
100
+ invalidLink();
101
+ continue;
102
+ }
103
+ }
104
+ const text = await optionalFile(join(child, 'SKILL.md'));
105
+ if (text !== undefined) {
106
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
107
+ let front = {};
108
+ try {
109
+ if (match)
110
+ front = object(parse(match[1], { maxAliasCount: 0 }));
111
+ }
112
+ catch {
113
+ /* invalid */
114
+ }
115
+ const valid = (front['name'] === undefined || front['name'] === entry.name) &&
116
+ typeof front['description'] === 'string' &&
117
+ !!front['description'].trim();
118
+ const flat = depth === 0;
119
+ const provenance = flat
120
+ ? {
121
+ ...object(jsonLocks[entry.name]),
122
+ ...object(yamlLocks[entry.name]),
123
+ }
124
+ : {};
125
+ const vendored = flat &&
126
+ (target !== undefined ||
127
+ Object.hasOwn(jsonLocks, entry.name) ||
128
+ Object.hasOwn(yamlLocks, entry.name));
129
+ const skill = {
130
+ name: entry.name,
131
+ path: skillPath,
132
+ discovered: flat && valid,
133
+ state: !valid
134
+ ? 'invalid'
135
+ : !flat
136
+ ? 'not-discovered'
137
+ : vendored
138
+ ? 'vendored'
139
+ : 'own',
140
+ contentHash: await directoryHash(child),
141
+ };
142
+ if (target !== undefined)
143
+ skill.target = target;
144
+ if (typeof front['description'] === 'string')
145
+ skill.description = front['description'];
146
+ for (const key of ['source', 'ref', 'sha']) {
147
+ if (typeof provenance[key] === 'string') {
148
+ let value = provenance[key];
149
+ if (key === 'source') {
150
+ try {
151
+ const url = new URL(value);
152
+ url.username = '';
153
+ url.password = '';
154
+ url.search = '';
155
+ url.hash = '';
156
+ value = url.toString();
157
+ }
158
+ catch {
159
+ /* owner/repo or SSH syntax */
160
+ }
161
+ }
162
+ skill[key] = value;
163
+ }
164
+ }
165
+ skills.push(skill);
166
+ }
167
+ else if (target !== undefined) {
168
+ invalidLink();
169
+ }
170
+ await walk(child, skillPath, depth + 1, new Set([...ancestors, child]));
171
+ }
172
+ }
173
+ await walk(skillsPath, '.claude/skills', 0, new Set([brainRoot, skillsPath]));
174
+ const present = new Set(envNames);
175
+ const mcpServers = Object.entries(object(document(mcpText)['mcpServers']))
176
+ .sort(([a], [b]) => a.localeCompare(b, 'en'))
177
+ .map(([name, raw]) => {
178
+ const server = object(raw);
179
+ const names = new Set();
180
+ // Only env/headers placeholder references are part of the credential contract.
181
+ for (const value of [
182
+ ...Object.values(object(server['env'])),
183
+ ...Object.values(object(server['headers'])),
184
+ ]) {
185
+ if (typeof value !== 'string')
186
+ continue;
187
+ for (const match of value.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}/g))
188
+ names.add(match[1]);
189
+ }
190
+ const requiredEnv = [...names].sort();
191
+ return {
192
+ name,
193
+ type: server['type'] === 'http' || server['type'] === 'sse' || server['url']
194
+ ? 'http'
195
+ : 'stdio',
196
+ requiredEnv,
197
+ missingEnv: requiredEnv.filter((name) => !present.has(name)),
198
+ };
199
+ });
200
+ const inventory = {
201
+ skills,
202
+ mcpServers,
203
+ locks: {
204
+ skillsLockJson: jsonText !== undefined,
205
+ skillsLockYaml: yamlText !== undefined,
206
+ },
207
+ };
208
+ return { ...inventory, inventoryHash: hash(JSON.stringify(inventory)) };
209
+ }
@@ -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 {
@@ -179,7 +179,7 @@ export interface BuildSpawnEnvParams {
179
179
  inboxThreadId: string;
180
180
  brainPath: string;
181
181
  subscriptionAccount?: SubscriptionAccount;
182
- gitCredential?: GitCredential;
182
+ gitCredential?: GitCredential | GitCredentialSet;
183
183
  gitTokenFile?: string;
184
184
  }
185
185
  /**
@@ -1,3 +1,5 @@
1
+ import { emitBrainInventory } from './brain-inventory-emitter.js';
2
+ import { END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL } from './end-of-turn-sentinel.js';
1
3
  import { createSessionInstance, writeSessionOwner } from './session-instance.js';
2
4
  import { buildGitCredentialEnv } from './git-credential-env.js';
3
5
  import { createGitCredentialSession, finishGitCredentialSession, setGitCredentialSessionPid } from './git-credential-session.js';
@@ -794,10 +796,10 @@ function resolveTypingStatusDetail(event, assistantText) {
794
796
  if (assistantCalledTool(event, 'mcp__slack__send_message'))
795
797
  return null;
796
798
  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' };
799
+ // tv#589: the silence marker is control text, including inside narration.
800
+ const narration = text?.split(END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL).join('').replace(/\s+/g, ' ').trim();
801
+ if (narration) {
802
+ return { text: oneLineStatus(narration), source: 'comment' };
801
803
  }
802
804
  for (const block of event.message.content) {
803
805
  if (block?.type !== 'tool_use' || typeof block.name !== 'string')
@@ -809,6 +811,9 @@ function resolveTypingStatusDetail(event, assistantText) {
809
811
  return { text: oneLineStatus(status), source: `tool:${safeToolName}` };
810
812
  }
811
813
  }
814
+ // A sentinel-only event keeps the current status when no tool status applies.
815
+ if (text?.includes(END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL))
816
+ return null;
812
817
  return { text: 'Thinking...', source: 'default' };
813
818
  }
814
819
  export function resolveTypingStatus(event, assistantText) {
@@ -909,6 +914,40 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
909
914
  workspaceId: msg.teamvibe.workspaceId,
910
915
  channelId: msg.teamvibe.channelId,
911
916
  });
917
+ // Prepend persistent bin dir to PATH if configured
918
+ const envPath = config.PERSISTENT_STORAGE_PATH
919
+ ? `${config.PERSISTENT_STORAGE_PATH}/bin:${process.env['PATH'] || ''}`
920
+ : process.env['PATH'];
921
+ if (runtime?.account)
922
+ poolInfo(sessionLog, `token target account=${poolLabel(runtime.account)} session=${JSON.stringify(sessionId ?? 'new')} credential=selected account token`);
923
+ const spawnEnv = buildSpawnEnv({
924
+ spawnSecretsEnv: spawnSecrets.env.unwrap(),
925
+ processEnv: process.env,
926
+ envPath,
927
+ claudeConfigDir: runtime?.claudeConfigDir ?? getBaseBrainPath(),
928
+ persistentStoragePath: config.PERSISTENT_STORAGE_PATH,
929
+ slackContext,
930
+ teamvibeApiUrl: config.TEAMVIBE_API_URL,
931
+ teamvibePollerToken: config.TEAMVIBE_POLLER_TOKEN || '',
932
+ teamvibeWorkspaceId: msg.teamvibe.workspaceId,
933
+ teamvibeChannelId: msg.teamvibe.channelId,
934
+ teamvibeBotId: msg.teamvibe.botId,
935
+ teamvibePollerId: getPollerId() || '',
936
+ inboxThreadId: msg.thread_id,
937
+ brainPath: directory.path,
938
+ subscriptionAccount: runtime?.account,
939
+ gitCredential,
940
+ gitTokenFile: gitDelivery.session?.tokenFile,
941
+ });
942
+ // Ordinary and maintenance sessions pass here after pull and all credential overlays.
943
+ // Only defined names from the final spawn environment cross the collector boundary.
944
+ if (directory.brainId && msg.teamvibe.brain?.gitRepoUrl) {
945
+ await emitBrainInventory({
946
+ brainPath: cwd, brainId: directory.brainId, workspaceId: directory.workspaceId,
947
+ channelId: msg.teamvibe.channelId, repo: msg.teamvibe.brain.gitRepoUrl,
948
+ envNames: Object.keys(spawnEnv).filter(name => spawnEnv[name] !== undefined),
949
+ });
950
+ }
912
951
  if (runtime?.account)
913
952
  await validateAccount(runtime.account, runtime.resolved);
914
953
  if (runtime?.account && !runtime.shouldStop()) {
@@ -1005,31 +1044,6 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
1005
1044
  const imageCount = (msg.inlineImages ?? []).filter((img) => img.data || img.url).length;
1006
1045
  sessionLog.info(`Inline image content: ${imageCount} image(s) sent via stream-json stdin (not logged)`);
1007
1046
  }
1008
- // Prepend persistent bin dir to PATH if configured
1009
- const envPath = config.PERSISTENT_STORAGE_PATH
1010
- ? `${config.PERSISTENT_STORAGE_PATH}/bin:${process.env['PATH'] || ''}`
1011
- : process.env['PATH'];
1012
- if (runtime?.account)
1013
- poolInfo(sessionLog, `token target account=${poolLabel(runtime.account)} session=${JSON.stringify(sessionId ?? 'new')} credential=selected account token`);
1014
- const spawnEnv = buildSpawnEnv({
1015
- spawnSecretsEnv: spawnSecrets.env.unwrap(),
1016
- processEnv: process.env,
1017
- envPath,
1018
- claudeConfigDir: runtime?.claudeConfigDir ?? getBaseBrainPath(),
1019
- persistentStoragePath: config.PERSISTENT_STORAGE_PATH,
1020
- slackContext,
1021
- teamvibeApiUrl: config.TEAMVIBE_API_URL,
1022
- teamvibePollerToken: config.TEAMVIBE_POLLER_TOKEN || '',
1023
- teamvibeWorkspaceId: msg.teamvibe.workspaceId,
1024
- teamvibeChannelId: msg.teamvibe.channelId,
1025
- teamvibeBotId: msg.teamvibe.botId,
1026
- teamvibePollerId: getPollerId() || '',
1027
- inboxThreadId: msg.thread_id,
1028
- brainPath: directory.path,
1029
- subscriptionAccount: runtime?.account,
1030
- gitCredential,
1031
- gitTokenFile: gitDelivery.session?.tokenFile,
1032
- });
1033
1047
  // Capture the actual credentials for this attempt, including pool selection
1034
1048
  // and secrets overlays, regardless of whether alias exposure is enabled.
1035
1049
  const outputTokens = [spawnEnv['CLAUDE_CODE_OAUTH_TOKEN'], spawnEnv['TEAMVIBE_CLAUDE_OAUTH_TOKEN'], spawnEnv['GH_TOKEN'], spawnEnv['GITHUB_TOKEN']]
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>;