@teamvibe/poller 0.8.9-rc.1 → 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
@@ -100,6 +100,41 @@ The poller authenticates with the TeamVibe API using your token, then continuous
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
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
+
103
138
  ## Channel Git credentials
104
139
 
105
140
  See [Agent Git credentials](docs/git-credentials.md) for multi-host channel bindings,
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { emitBrainInventory } from './brain-inventory-emitter.js';
1
2
  import { END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL } from './end-of-turn-sentinel.js';
2
3
  import { createSessionInstance, writeSessionOwner } from './session-instance.js';
3
4
  import { buildGitCredentialEnv } from './git-credential-env.js';
@@ -913,6 +914,40 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
913
914
  workspaceId: msg.teamvibe.workspaceId,
914
915
  channelId: msg.teamvibe.channelId,
915
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
+ }
916
951
  if (runtime?.account)
917
952
  await validateAccount(runtime.account, runtime.resolved);
918
953
  if (runtime?.account && !runtime.shouldStop()) {
@@ -1009,31 +1044,6 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
1009
1044
  const imageCount = (msg.inlineImages ?? []).filter((img) => img.data || img.url).length;
1010
1045
  sessionLog.info(`Inline image content: ${imageCount} image(s) sent via stream-json stdin (not logged)`);
1011
1046
  }
1012
- // Prepend persistent bin dir to PATH if configured
1013
- const envPath = config.PERSISTENT_STORAGE_PATH
1014
- ? `${config.PERSISTENT_STORAGE_PATH}/bin:${process.env['PATH'] || ''}`
1015
- : process.env['PATH'];
1016
- if (runtime?.account)
1017
- poolInfo(sessionLog, `token target account=${poolLabel(runtime.account)} session=${JSON.stringify(sessionId ?? 'new')} credential=selected account token`);
1018
- const spawnEnv = buildSpawnEnv({
1019
- spawnSecretsEnv: spawnSecrets.env.unwrap(),
1020
- processEnv: process.env,
1021
- envPath,
1022
- claudeConfigDir: runtime?.claudeConfigDir ?? getBaseBrainPath(),
1023
- persistentStoragePath: config.PERSISTENT_STORAGE_PATH,
1024
- slackContext,
1025
- teamvibeApiUrl: config.TEAMVIBE_API_URL,
1026
- teamvibePollerToken: config.TEAMVIBE_POLLER_TOKEN || '',
1027
- teamvibeWorkspaceId: msg.teamvibe.workspaceId,
1028
- teamvibeChannelId: msg.teamvibe.channelId,
1029
- teamvibeBotId: msg.teamvibe.botId,
1030
- teamvibePollerId: getPollerId() || '',
1031
- inboxThreadId: msg.thread_id,
1032
- brainPath: directory.path,
1033
- subscriptionAccount: runtime?.account,
1034
- gitCredential,
1035
- gitTokenFile: gitDelivery.session?.tokenFile,
1036
- });
1037
1047
  // Capture the actual credentials for this attempt, including pool selection
1038
1048
  // and secrets overlays, regardless of whether alias exposure is enabled.
1039
1049
  const outputTokens = [spawnEnv['CLAUDE_CODE_OAUTH_TOKEN'], spawnEnv['TEAMVIBE_CLAUDE_OAUTH_TOKEN'], spawnEnv['GH_TOKEN'], spawnEnv['GITHUB_TOKEN']]
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.9-rc.1",
3
+ "version": "0.8.9-rc.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@teamvibe/poller",
9
- "version": "0.8.9-rc.1",
9
+ "version": "0.8.9-rc.2",
10
10
  "dependencies": {
11
11
  "@aws-sdk/client-dynamodb": "3.1108.0",
12
12
  "@aws-sdk/client-sqs": "3.1108.0",
@@ -15,6 +15,7 @@
15
15
  "@slack/web-api": "^7.0.0",
16
16
  "croner": "^10.0.1",
17
17
  "dotenv": "^17.2.3",
18
+ "yaml": "2.8.1",
18
19
  "zod": "^3.22.0"
19
20
  },
20
21
  "bin": {
@@ -1594,6 +1595,18 @@
1594
1595
  "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
1595
1596
  "license": "MIT"
1596
1597
  },
1598
+ "node_modules/yaml": {
1599
+ "version": "2.8.1",
1600
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
1601
+ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
1602
+ "license": "ISC",
1603
+ "bin": {
1604
+ "yaml": "bin.mjs"
1605
+ },
1606
+ "engines": {
1607
+ "node": ">= 14.6"
1608
+ }
1609
+ },
1597
1610
  "node_modules/zod": {
1598
1611
  "version": "3.25.76",
1599
1612
  "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.9-rc.1",
3
+ "version": "0.8.9-rc.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {
@@ -34,6 +34,7 @@
34
34
  "@slack/web-api": "^7.0.0",
35
35
  "croner": "^10.0.1",
36
36
  "dotenv": "^17.2.3",
37
+ "yaml": "2.8.1",
37
38
  "zod": "^3.22.0"
38
39
  },
39
40
  "devDependencies": {