@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 +40 -0
- package/bin/tv-git-credential.mjs +11 -6
- package/dist/brain-inventory-emitter.d.ts +13 -0
- package/dist/brain-inventory-emitter.js +57 -0
- package/dist/brain-inventory.d.ts +28 -0
- package/dist/brain-inventory.js +209 -0
- package/dist/brain-manager.js +4 -4
- package/dist/claude-spawner.d.ts +2 -2
- package/dist/claude-spawner.js +43 -29
- package/dist/config.d.ts +4 -0
- package/dist/config.js +1 -0
- package/dist/connections-client.d.ts +16 -4
- package/dist/connections-client.js +42 -11
- package/dist/end-of-turn-sentinel.d.ts +2 -0
- package/dist/end-of-turn-sentinel.js +2 -0
- package/dist/git-credential-bindings.d.ts +2 -0
- package/dist/git-credential-bindings.js +4 -0
- package/dist/git-credential-env.d.ts +2 -2
- package/dist/git-credential-env.js +22 -9
- package/dist/git-credential-session.d.ts +6 -6
- package/dist/git-credential-session.js +24 -4
- package/dist/inbox-watcher.d.ts +2 -2
- package/dist/inbox-watcher.js +20 -13
- package/dist/poller.d.ts +4 -3
- package/dist/poller.js +43 -8
- package/dist/redact-text.js +1 -1
- package/dist/slack-client.d.ts +3 -1
- package/dist/slack-client.js +84 -12
- package/dist/thread-routing.d.ts +7 -0
- package/dist/thread-routing.js +7 -0
- package/npm-shrinkwrap.json +15 -2
- package/package.json +2 -1
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
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/brain-manager.js
CHANGED
|
@@ -29,7 +29,7 @@ if (config.PERSISTENT_STORAGE_PATH) {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
const gitCredentialContext = new AsyncLocalStorage();
|
|
32
|
-
const
|
|
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 || !
|
|
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
|
|
257
|
-
|
|
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) {
|
package/dist/claude-spawner.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/claude-spawner.js
CHANGED
|
@@ -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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
return { text: oneLineStatus(
|
|
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:
|
|
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
|
|
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:
|
|
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>;
|