@teamvibe/poller 0.8.6 → 0.8.7-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.
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ // Defence in depth for direct invocations that bypass Git's env -u launcher.
3
+ for (const key of ['GITHUB_TOKEN', 'GH_TOKEN', 'GITLAB_TOKEN']) delete process.env[key]
4
+
5
+ import { appendFileSync, readFileSync } from 'node:fs'
6
+ import { dirname, join } from 'node:path'
7
+
8
+ // Git calls this executable with only get/store/erase in argv. Read each get
9
+ // afresh; never cache credentials or ask for input beyond Git's request.
10
+ if (process.argv[2] === 'get') {
11
+ try {
12
+ const request = {}
13
+ for (const line of readFileSync(0, 'utf8').split('\n')) {
14
+ if (!line) break
15
+ const separator = line.indexOf('=')
16
+ if (separator > 0) request[line.slice(0, separator)] = line.slice(separator + 1)
17
+ }
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' ||
22
+ !credential.token || /[\r\n\0]/.test(credential.token) ||
23
+ !['github_token', 'github_app'].includes(credential.kind)) process.exit(0)
24
+ if (credential.expiresAt !== undefined) {
25
+ const expiry = Date.parse(credential.expiresAt)
26
+ if (!Number.isFinite(expiry) || expiry <= Date.now()) {
27
+ const diagnostic = `git_credential_expired kind=${credential.kind}\n`
28
+ process.stderr.write(diagnostic)
29
+ // Value-free spool lets the poller report detached-task expiry through
30
+ // its usual error queue even after the launching agent has exited.
31
+ try { appendFileSync(join(dirname(file), 'git-credential-events'), diagnostic, { mode: 0o600 }) } catch {}
32
+ process.exit(0)
33
+ }
34
+ }
35
+ process.stdout.write(`username=x-access-token\npassword=${credential.token}\n`)
36
+ } catch {
37
+ // No output and success means Git proceeds to its prompt-disabled failure.
38
+ }
39
+ }
@@ -2,6 +2,7 @@ import { config, POLLER_VERSION } from './config.js';
2
2
  import { logger } from './logger.js';
3
3
  import { emitError } from './event-queue.js';
4
4
  import { getBaseBrainCommitSha } from './base-brain-metadata.js';
5
+ import { connectionsClient, refreshConnectionTuples } from './connections-client.js';
5
6
  export class AuthHttpError extends Error {
6
7
  status;
7
8
  constructor(status, _body) {
@@ -24,6 +25,12 @@ const REFRESH_INTERVAL_MS = 50 * 60 * 1000; // 50 minutes (before 1hr expiry)
24
25
  const HEARTBEAT_INTERVAL_MS = 60 * 1000; // 1 minute
25
26
  async function sendHeartbeat(apiUrl, token, version) {
26
27
  try {
28
+ if (currentPollerId) {
29
+ try {
30
+ await refreshConnectionTuples(currentPollerId);
31
+ }
32
+ catch { /* Heartbeat must still report liveness if tuple refresh fails. */ }
33
+ }
27
34
  const baseBrainSha = await getBaseBrainCommitSha(config.BASE_BRAIN_PATH);
28
35
  if (!baseBrainSha)
29
36
  logger.warn('[Heartbeat] Could not read base-brain HEAD for pin readback');
@@ -33,7 +40,7 @@ async function sendHeartbeat(apiUrl, token, version) {
33
40
  Authorization: `Bearer ${token}`,
34
41
  'Content-Type': 'application/json',
35
42
  },
36
- body: JSON.stringify({ version, baseBrainSha, baseBrainBranch: config.BASE_BRAIN_BRANCH }),
43
+ body: JSON.stringify({ version, baseBrainSha, baseBrainBranch: config.BASE_BRAIN_BRANCH, legacyGitBrains: connectionsClient.legacyGitBrains }),
37
44
  });
38
45
  if (!response.ok) {
39
46
  logger.warn(`Heartbeat failed (${response.status})`);
@@ -0,0 +1,12 @@
1
+ export interface BgTaskStatus {
2
+ state?: string;
3
+ runner_crashed?: string;
4
+ pid?: string;
5
+ started?: string;
6
+ ttl?: string;
7
+ }
8
+ export declare function parseStatus(text: string): BgTaskStatus;
9
+ export declare function pidAlive(pid: number): boolean;
10
+ export declare function lifecycleOf(kv: BgTaskStatus, now: number): string;
11
+ export declare function bgTaskRoot(): string;
12
+ export declare function listDirs(path: string): string[];
@@ -0,0 +1,65 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { config } from './config.js';
4
+ // Shared with idle restart and credential lifetime cleanup. Status belongs to
5
+ // poller-brain's background-task skill; no second task-liveness registry.
6
+ const ABANDON_GRACE_SEC = 60; // mirrors bg-task.mjs's ABANDON_GRACE_SEC
7
+ export function parseStatus(text) {
8
+ const kv = {};
9
+ for (const line of text.split('\n')) {
10
+ const i = line.indexOf('=');
11
+ if (i > 0)
12
+ kv[line.slice(0, i)] = line.slice(i + 1);
13
+ }
14
+ return kv;
15
+ }
16
+ export function pidAlive(pid) {
17
+ try {
18
+ process.kill(pid, 0);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ // Same four-way classification as bg-task.mjs's lifecycleOf, minus the tests-only
26
+ // `alive` injection point (this module has its own dedicated tests instead).
27
+ export function lifecycleOf(kv, now) {
28
+ // Trust the runner's own claim, whatever it says (bg-task.mjs:139) — not just the two
29
+ // values the runner writes today (`finished`/`timed-out`, bg-task-runner.mjs:403).
30
+ // Mirroring the check itself, not just its current output, means the two
31
+ // implementations of this format can't quietly diverge if the runner ever starts
32
+ // writing a state value this list doesn't happen to enumerate — hardcoding a closed
33
+ // set here would silently fall through to the pid/TTL heuristics below instead.
34
+ if (kv.state)
35
+ return kv.state;
36
+ if (kv.runner_crashed)
37
+ return 'crashed';
38
+ if (Object.keys(kv).length === 0)
39
+ return 'unknown';
40
+ const pid = Number(kv.pid);
41
+ if (!Number.isInteger(pid) || pid <= 0 || !pidAlive(pid))
42
+ return 'abandoned';
43
+ const started = kv.started ? Date.parse(kv.started) : NaN;
44
+ const ttl = Number(String(kv.ttl ?? '').replace(/s$/, ''));
45
+ if (Number.isFinite(started) && Number.isFinite(ttl) && now - started > (ttl + ABANDON_GRACE_SEC) * 1000) {
46
+ return 'abandoned';
47
+ }
48
+ return 'running';
49
+ }
50
+ export function bgTaskRoot() {
51
+ // Mirrors bg-task.mjs's taskRoot() default exactly, minus the per-channel
52
+ // namespace — the poller checks liveness across every channel it serves, not just
53
+ // one, so it walks the parent directory instead of joining a single channel ID.
54
+ return process.env['BG_TASK_ROOT'] || join(config.PERSISTENT_STORAGE_PATH || '/tmp', 'bg-tasks');
55
+ }
56
+ export function listDirs(path) {
57
+ try {
58
+ return readdirSync(path, { withFileTypes: true })
59
+ .filter((entry) => entry.isDirectory())
60
+ .map((entry) => entry.name);
61
+ }
62
+ catch {
63
+ return [];
64
+ }
65
+ }
@@ -1,3 +1,7 @@
1
+ import { exec } from 'child_process';
2
+ declare const execAsync: typeof exec.__promisify__;
3
+ /** @internal Inject a git process for clone/push contract tests. */
4
+ export declare function __setGitExecForTest(runner?: typeof execAsync): void;
1
5
  export declare function withBrainLock<T>(key: string, fn: () => Promise<T>): Promise<T>;
2
6
  interface BrainConfig {
3
7
  brainId: string;
@@ -1,3 +1,4 @@
1
+ import { createGitCredentialSession, finishGitCredentialSession } from './git-credential-session.js';
1
2
  import { exec } from 'child_process';
2
3
  import { promisify } from 'util';
3
4
  import { existsSync, mkdirSync } from 'fs';
@@ -8,7 +9,14 @@ import { logger } from './logger.js';
8
9
  import { emitError } from './event-queue.js';
9
10
  import { brainLockKey, resolveBrainDir, fallbackBrainPath, legacyBrainPath } from './brain-path.js';
10
11
  import { startupFailureDetail } from './startup-auth.js';
12
+ import { AsyncLocalStorage } from 'node:async_hooks';
13
+ import { connectionsClient } from './connections-client.js';
14
+ import { buildGitCredentialEnv } from './git-credential-env.js';
15
+ import { redactGitDiagnostic, redactRepoUrl } from './redact-text.js';
11
16
  const execAsync = promisify(exec);
17
+ let gitRunner = execAsync;
18
+ /** @internal Inject a git process for clone/push contract tests. */
19
+ export function __setGitExecForTest(runner) { gitRunner = runner ?? execAsync; }
12
20
  // Clean env for git commands:
13
21
  // - npm/npx sets GIT_ASKPASS which prevents credential helpers from working
14
22
  // - PERSISTENT_STORAGE_PATH/bin may contain tools like gh not in default PATH
@@ -20,8 +28,68 @@ if (config.PERSISTENT_STORAGE_PATH) {
20
28
  gitEnv['PATH'] = `${binDir}:${gitEnv['PATH'] || ''}`;
21
29
  }
22
30
  }
23
- function gitExec(command, options) {
24
- return execAsync(command, { ...options, env: gitEnv });
31
+ const gitCredentialContext = new AsyncLocalStorage();
32
+ const githubBrains = new Set();
33
+ async function gitExec(command, options) {
34
+ const context = gitCredentialContext.getStore();
35
+ const run = async (credential) => {
36
+ const session = credential?.source === 'platform'
37
+ ? createGitCredentialSession(credential, { channelId: '', threadId: `brain:${context?.brainId ?? ''}` }) : undefined;
38
+ try {
39
+ return await gitRunner(command, { ...options, env: credential ? buildGitCredentialEnv(gitEnv, credential, session?.tokenFile) : gitEnv });
40
+ }
41
+ finally {
42
+ if (session)
43
+ finishGitCredentialSession(session);
44
+ }
45
+ };
46
+ const recorded = () => {
47
+ if (context && /^git (?:clone|fetch|pull|push|submodule)\b/.test(command))
48
+ connectionsClient.recordBrainSource(context.brainId, context.credential.source === 'platform' ? 'platform' : 'legacy');
49
+ };
50
+ const safeResult = (result) => ({ stdout: redactGitDiagnostic(result.stdout), stderr: redactGitDiagnostic(result.stderr) });
51
+ const safeError = (error) => {
52
+ if (error instanceof Error) {
53
+ error.message = redactGitDiagnostic(error.message);
54
+ const detail = error;
55
+ if (detail.stdout)
56
+ detail.stdout = redactGitDiagnostic(detail.stdout);
57
+ if (detail.stderr)
58
+ detail.stderr = redactGitDiagnostic(detail.stderr);
59
+ return error;
60
+ }
61
+ return new Error(redactGitDiagnostic(String(error)));
62
+ };
63
+ try {
64
+ const result = await run(context?.credential);
65
+ recorded();
66
+ return safeResult(result);
67
+ }
68
+ catch (error) {
69
+ const message = error instanceof Error ? error.message : String(error);
70
+ if (!context || context.credential.source !== 'platform' || !/(?:HTTP|returned error:)\s*(?:401|403)|Authentication failed/i.test(message))
71
+ throw safeError(error);
72
+ connectionsClient.invalidate('brain', context.brainId);
73
+ const refreshed = await connectionsClient.get('brain', context.brainId, true);
74
+ if (refreshed.source !== 'platform')
75
+ throw safeError(error);
76
+ context.credential = refreshed;
77
+ let result;
78
+ try {
79
+ result = await run(refreshed);
80
+ }
81
+ catch (retryError) {
82
+ throw safeError(retryError);
83
+ } // exactly one remote-auth retry
84
+ recorded();
85
+ return safeResult(result);
86
+ }
87
+ }
88
+ async function withGitCredential(brainId, workspaceId, fn) {
89
+ if (!workspaceId || !githubBrains.has(brainId) || !config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN)
90
+ return fn();
91
+ const credential = await connectionsClient.get('brain', brainId);
92
+ return gitCredentialContext.run({ brainId, credential }, fn);
25
93
  }
26
94
  // Node's child_process.exec() defaults to a 1MB stdout cap. This bounds the
27
95
  // text output of a git plumbing command (e.g. one line per object listed),
@@ -119,7 +187,7 @@ async function reportBrainSync(brainId, workspaceId, event) {
119
187
  Authorization: `Bearer ${config.TEAMVIBE_POLLER_TOKEN}`,
120
188
  'Content-Type': 'application/json',
121
189
  },
122
- body: JSON.stringify({ brainId, workspaceId, event }),
190
+ body: JSON.stringify({ brainId: redactGitDiagnostic(brainId), workspaceId: redactGitDiagnostic(workspaceId), event, source: gitCredentialContext.getStore()?.credential.source === 'platform' ? 'platform' : 'legacy' }),
123
191
  });
124
192
  if (!response.ok) {
125
193
  logger.warn(`Brain sync report failed (${response.status})`);
@@ -127,8 +195,9 @@ async function reportBrainSync(brainId, workspaceId, event) {
127
195
  }
128
196
  }
129
197
  catch (error) {
130
- logger.warn(`Brain sync report error: ${error instanceof Error ? error.message : error}`);
131
- emitError('brain', `Brain sync report error: ${error instanceof Error ? error.message : String(error)}`, { severity: 'warn', context: { brainId } });
198
+ const diagnostic = redactGitDiagnostic(error instanceof Error ? error.message : String(error));
199
+ logger.warn(`Brain sync report error: ${diagnostic}`);
200
+ emitError('brain', `Brain sync report error: ${diagnostic}`, { severity: 'warn', context: { brainId: redactGitDiagnostic(brainId) } });
132
201
  }
133
202
  }
134
203
  // tv#247: local-only (never committed, never synced) merge-driver config for
@@ -184,7 +253,9 @@ export async function getBrainPath(brain, channelId, workspaceId) {
184
253
  return config.DEFAULT_BRAIN_PATH;
185
254
  }
186
255
  const brainDir = resolveBrainDir(workspaceId, brain.brainId);
187
- return withBrainLock(brainLockKey(brainDir, brain.brainId, workspaceId), () => getWorkspaceBrainPath(brain, workspaceId));
256
+ if (/^https:\/\/github\.com\//i.test(brain.gitRepoUrl))
257
+ githubBrains.add(brain.brainId);
258
+ return withBrainLock(brainLockKey(brainDir, brain.brainId, workspaceId), () => withGitCredential(brain.brainId, workspaceId, () => getWorkspaceBrainPath(brain, workspaceId)));
188
259
  }
189
260
  async function getWorkspaceBrainPath(brain, workspaceId) {
190
261
  const brainDir = resolveBrainDir(workspaceId, brain.brainId);
@@ -195,7 +266,7 @@ async function getWorkspaceBrainPath(brain, workspaceId) {
195
266
  }
196
267
  mkdirSync(dirname(brainDir), { recursive: true });
197
268
  if (!existsSync(brainDir)) {
198
- logger.info(`Cloning brain ${brain.brainId} from ${brain.gitRepoUrl} (branch: ${brain.branch})...`);
269
+ logger.info(`Cloning brain ${brain.brainId} from ${redactRepoUrl(brain.gitRepoUrl)} (branch: ${brain.branch})...`);
199
270
  await gitExec(`git clone --recurse-submodules ${brain.gitRepoUrl} ${brainDir}`);
200
271
  // Ensure the desired branch exists (handles empty repos and missing branches)
201
272
  const { stdout: currentBranch } = await gitExec('git branch --show-current', { cwd: brainDir });
@@ -214,8 +285,8 @@ async function getWorkspaceBrainPath(brain, workspaceId) {
214
285
  reportBrainSync(brain.brainId, workspaceId, 'pulled');
215
286
  }
216
287
  else if (config.BRAIN_AUTO_UPDATE) {
217
- await updateBrainLocked(brainDir, brain.brainId, brain.branch, `${workspaceId}:${brain.brainId}`);
218
- if (workspaceId)
288
+ const pulled = await updateBrainLocked(brainDir, brain.brainId, brain.branch, `${workspaceId}:${brain.brainId}`);
289
+ if (pulled && workspaceId)
219
290
  reportBrainSync(brain.brainId, workspaceId, 'pulled');
220
291
  }
221
292
  // Idempotent, local-only — covers both the fresh-clone and existing-dir
@@ -229,14 +300,14 @@ async function getWorkspaceBrainPath(brain, workspaceId) {
229
300
  */
230
301
  export async function updateBrain(brainDir, brainId, branch, workspaceId) {
231
302
  const key = brainLockKey(brainDir, brainId, workspaceId);
232
- return withBrainLock(key, () => updateBrainLocked(brainDir, brainId, branch, key));
303
+ return withBrainLock(key, () => withGitCredential(brainId, workspaceId, async () => { await updateBrainLocked(brainDir, brainId, branch, key); }));
233
304
  }
234
305
  async function updateBrainLocked(brainDir, brainId, branch, key) {
235
306
  const now = Date.now();
236
307
  const lastUpdate = lastUpdateTimes.get(key) || 0;
237
308
  if (now - lastUpdate < config.BRAIN_UPDATE_COOLDOWN_MS) {
238
309
  logger.debug(`Brain ${brainId} update skipped - cooldown active (${Math.round((config.BRAIN_UPDATE_COOLDOWN_MS - (now - lastUpdate)) / 1000)}s remaining)`);
239
- return;
310
+ return false;
240
311
  }
241
312
  // Guard: skip `reset --hard` when the working tree has uncommitted writes or
242
313
  // unpushed commits, otherwise an in-flight session's mid-message memory
@@ -265,7 +336,7 @@ async function updateBrainLocked(brainDir, brainId, branch, key) {
265
336
  // long-term skip means pushBrainChanges is persistently failing and the brain is
266
337
  // silently drifting away from base-brain / skills updates.
267
338
  logger.info(`Brain ${brainId} update skipped — local changes present (dirty=${!!dirty.trim()}, hasLocalCommits=${hasLocalCommits})`);
268
- return;
339
+ return false;
269
340
  }
270
341
  }
271
342
  catch (error) {
@@ -273,7 +344,7 @@ async function updateBrainLocked(brainDir, brainId, branch, key) {
273
344
  // the next iteration will retry.
274
345
  const errorMessage = startupFailureDetail(error, 'BaseBrain', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO]);
275
346
  logger.warn(`Brain ${brainId} pre-check failed, skipping update: ${errorMessage}`);
276
- return;
347
+ return false;
277
348
  }
278
349
  await cleanupStaleLocks(brainDir);
279
350
  try {
@@ -283,11 +354,13 @@ async function updateBrainLocked(brainDir, brainId, branch, key) {
283
354
  });
284
355
  lastUpdateTimes.set(key, Date.now());
285
356
  logger.info(`Brain ${brainId} updated successfully`);
357
+ return true;
286
358
  }
287
359
  catch (error) {
288
360
  const errorMessage = startupFailureDetail(error, 'BaseBrain', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO]);
289
361
  logger.error(`Failed to update brain ${brainId}: ${errorMessage}`);
290
362
  emitError('brain', `Failed to update brain ${brainId}: ${errorMessage}`, { context: { brainId, brainDir } });
363
+ return false;
291
364
  }
292
365
  }
293
366
  /**
@@ -421,7 +494,7 @@ export async function findOversizedBlobs(brainDir, maxBytes) {
421
494
  * process-wide env/module-load-order tricks.
422
495
  */
423
496
  export async function pushBrainChanges(brainDir, brainId, workspaceId, retryOptions) {
424
- return withBrainLock(brainLockKey(brainDir, brainId, workspaceId), () => pushBrainChangesLocked(brainDir, brainId, workspaceId, retryOptions));
497
+ return withBrainLock(brainLockKey(brainDir, brainId, workspaceId), () => withGitCredential(brainId, workspaceId, () => pushBrainChangesLocked(brainDir, brainId, workspaceId, retryOptions)));
425
498
  }
426
499
  async function pushBrainChangesLocked(brainDir, brainId, workspaceId, retryOptions) {
427
500
  await cleanupStaleLocks(brainDir);
@@ -3,6 +3,7 @@ import { logger } from './logger.js';
3
3
  import { getPollerId } from './auth-provider.js';
4
4
  import { seedTrackedBrains } from './maintenance-cron.js';
5
5
  import { emitEvent, emitError } from './event-queue.js';
6
+ import { connectionsClient } from './connections-client.js';
6
7
  // Client-side timeout kept comfortably above the server-side route timeout
7
8
  // (infra/poller-auth.ts: GET /poller/brains, 25s) so the poller never aborts
8
9
  // a request the Lambda could still have answered (DevGuru round-2 review —
@@ -80,7 +81,7 @@ async function attemptFetch(url, token, timeoutMs) {
80
81
  if (!body || typeof body !== 'object' || !Array.isArray(body.brains)) {
81
82
  return { ok: false, reason: 'malformed-body', detail: 'missing brains array', retryable: false };
82
83
  }
83
- return { ok: true, brains: body.brains };
84
+ return { ok: true, brains: body.brains, channels: body.channels };
84
85
  }
85
86
  catch (err) {
86
87
  const isTimeout = err instanceof Error && err.name === 'AbortError';
@@ -97,7 +98,8 @@ async function attemptFetch(url, token, timeoutMs) {
97
98
  }
98
99
  /** Seed trackedBrains from a successful fetch and emit success telemetry.
99
100
  * Shared by both the critical-path attempt and the detached retry below. */
100
- function applySuccess(brains) {
101
+ function applySuccess(brains, channels) {
102
+ connectionsClient.refreshTuples(brains, channels);
101
103
  const seeded = seedTrackedBrains(brains);
102
104
  logger.info(`[Bootstrap] Seeded ${seeded} brain(s) into trackedBrains from backend (${brains.length} returned)`);
103
105
  emitEvent('poller.brains_bootstrap.success', 'system', {
@@ -176,7 +178,7 @@ export async function bootstrapTrackedBrains() {
176
178
  const url = `${config.TEAMVIBE_API_URL}/poller/brains?pollerId=${encodeURIComponent(pollerId)}`;
177
179
  const outcome = await attemptFetch(url, token, CRITICAL_PATH_TIMEOUT_MS);
178
180
  if (outcome.ok) {
179
- applySuccess(outcome.brains);
181
+ applySuccess(outcome.brains, outcome.channels);
180
182
  return;
181
183
  }
182
184
  logger.warn(`[Bootstrap] GET /poller/brains failed on first attempt: ${outcome.reason} (${outcome.detail})`);
@@ -204,7 +206,7 @@ export async function bootstrapTrackedBrains() {
204
206
  await sleep(RETRY_DELAY_MS);
205
207
  const retryOutcome = await attemptFetch(url, token, FETCH_TIMEOUT_MS);
206
208
  if (retryOutcome.ok) {
207
- applySuccess(retryOutcome.brains);
209
+ applySuccess(retryOutcome.brains, retryOutcome.channels);
208
210
  return;
209
211
  }
210
212
  logger.warn(`[Bootstrap] GET /poller/brains failed on retry: ${retryOutcome.reason} (${retryOutcome.detail})`);
@@ -9,6 +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
13
  export declare function progressNotificationMarks(estimateMinutes: number): number[];
13
14
  export declare function computeCompactionKillDeadline(previousActivityTime: number, now: number, idleKillMs: number, graceMs: number): number;
14
15
  export interface ContextHintParams {
@@ -176,6 +177,8 @@ export interface BuildSpawnEnvParams {
176
177
  inboxThreadId: string;
177
178
  brainPath: string;
178
179
  subscriptionAccount?: SubscriptionAccount;
180
+ gitCredential?: GitCredential;
181
+ gitTokenFile?: string;
179
182
  }
180
183
  /**
181
184
  * Pure construction of the env object passed to the spawned `claude` child
@@ -1,8 +1,11 @@
1
+ import { createSessionInstance, writeSessionOwner } from './session-instance.js';
2
+ import { buildGitCredentialEnv } from './git-credential-env.js';
3
+ import { createGitCredentialSession, finishGitCredentialSession, setGitCredentialSessionPid } from './git-credential-session.js';
1
4
  import { blockingUsageWindows, normalizePassiveUsage, selectSubscriptionAccount } from './subscription-selection.js';
2
5
  import { logPoolFetch, logPoolUsageWrite, poolInfo, poolLabel } from './pool-logging.js';
3
6
  import { emitPoolSelection, accountUsageTelemetry, emitCandidateUsage, poolRunTelemetry } from './pool-telemetry.js';
4
7
  import { spawn } from 'child_process';
5
- import { join } from 'path';
8
+ import { join, dirname } from 'path';
6
9
  import { readFileSync, existsSync } from 'fs';
7
10
  import { WebClient } from '@slack/web-api';
8
11
  import { config } from './config.js';
@@ -21,6 +24,7 @@ import { abortLocalProbe, accountPin, preparePool, fetchRuntimePool, validateAcc
21
24
  import { selectPoolForResolvedModel } from './subscription-pool.js';
22
25
  import { isQuotaPaused, QuotaPausedError } from './quota-pause.js';
23
26
  import { redactStartupText } from './startup-auth.js';
27
+ import { connectionsClient } from './connections-client.js';
24
28
  /** Inspect Claude's user/project settings without changing any brain files. */
25
29
  function warnTokenAliasSettings(claudeConfigDir, cwd, brainPath, sessionLog) {
26
30
  // CLAUDE_CONFIG_DIR replaces ~/.claude for user settings. settings.local.json
@@ -464,7 +468,7 @@ export function buildSpawnEnv(params) {
464
468
  const exposeToken = !/^(0|false|no|off)$/i.test(params.processEnv['EXPOSE_CLAUDE_TOKEN_TO_AGENT'] ?? '');
465
469
  const accountToken = params.subscriptionAccount?.credential.unwrap();
466
470
  const sessionToken = accountToken ?? params.processEnv['CLAUDE_CODE_OAUTH_TOKEN'];
467
- return {
471
+ const env = {
468
472
  // Secrets platform (#105) injection — SOLE call site of
469
473
  // `SpawnSecretsEnvelope.unwrap()` in the entire poller codebase.
470
474
  // Placed FIRST so subsequent spreads can overwrite. Belt-and-
@@ -530,6 +534,12 @@ export function buildSpawnEnv(params) {
530
534
  CLAUDE_CODE_USE_FOUNDRY: undefined,
531
535
  } : {}),
532
536
  };
537
+ const git = params.gitCredential ?? { source: 'none' };
538
+ const gitEnv = buildGitCredentialEnv(env, git, params.gitTokenFile);
539
+ if (git.source === 'platform' && params.gitTokenFile) {
540
+ gitEnv['TV_GIT_BG_TASKS_FILE'] = join(dirname(params.gitTokenFile), 'bg-tasks');
541
+ }
542
+ return gitEnv;
533
543
  }
534
544
  export function buildPrompt(msg, contextHint) {
535
545
  const isEmail = msg.source === 'email';
@@ -848,7 +858,7 @@ function resultMetrics(event, pooled, lastAssistant) {
848
858
  metrics.context_window = window;
849
859
  return metrics;
850
860
  }
851
- async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, runtime) {
861
+ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, runtime, gitDelivery = {}) {
852
862
  const slackContext = msg.response_context.slack;
853
863
  const isCronMessage = msg.source === 'cron';
854
864
  const isMaintenance = msg.source === 'maintenance';
@@ -886,6 +896,10 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
886
896
  workspaceId: msg.teamvibe.workspaceId,
887
897
  channelId: msg.teamvibe.channelId,
888
898
  });
899
+ const gitCredential = gitDelivery.credential ??= await connectionsClient.get('channel', msg.teamvibe.channelId);
900
+ if (gitCredential.source === 'platform' && !gitDelivery.session) {
901
+ gitDelivery.session = createGitCredentialSession(gitCredential, { channelId: msg.teamvibe.channelId, threadId: msg.thread_id, instance: gitDelivery.instance });
902
+ }
889
903
  const secretsReminder = buildSecretsReminder(spawnSecrets.reminders);
890
904
  // Fetch scoped model config for this spawn — same hoisting rationale as
891
905
  // secrets above. Fail-soft: returns {} on any error, which resolveModel
@@ -1005,10 +1019,12 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
1005
1019
  inboxThreadId: msg.thread_id,
1006
1020
  brainPath: directory.path,
1007
1021
  subscriptionAccount: runtime?.account,
1022
+ gitCredential,
1023
+ gitTokenFile: gitDelivery.session?.tokenFile,
1008
1024
  });
1009
1025
  // Capture the actual credentials for this attempt, including pool selection
1010
1026
  // and secrets overlays, regardless of whether alias exposure is enabled.
1011
- const outputTokens = [spawnEnv['CLAUDE_CODE_OAUTH_TOKEN'], spawnEnv['TEAMVIBE_CLAUDE_OAUTH_TOKEN']]
1027
+ const outputTokens = [spawnEnv['CLAUDE_CODE_OAUTH_TOKEN'], spawnEnv['TEAMVIBE_CLAUDE_OAUTH_TOKEN'], spawnEnv['GH_TOKEN'], spawnEnv['GITHUB_TOKEN']]
1012
1028
  .filter((token) => !!token);
1013
1029
  const redactOutput = (text) => redactStartupText(text, outputTokens);
1014
1030
  const safeEvent = (value) => {
@@ -1020,7 +1036,7 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
1020
1036
  return value;
1021
1037
  return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
1022
1038
  redactOutput(key),
1023
- /^(?:CLAUDE_CODE_OAUTH_TOKEN|TEAMVIBE_CLAUDE_OAUTH_TOKEN)$/i.test(key) ? '[REDACTED]' : safeEvent(entry),
1039
+ /^(?:CLAUDE_CODE_OAUTH_TOKEN|TEAMVIBE_CLAUDE_OAUTH_TOKEN|GH_TOKEN|GITHUB_TOKEN|GITLAB_TOKEN)$/i.test(key) ? '[REDACTED]' : safeEvent(entry),
1024
1040
  ]));
1025
1041
  };
1026
1042
  warnTokenAliasSettings(spawnEnv['CLAUDE_CONFIG_DIR'], cwd, directory.path, sessionLog);
@@ -1042,6 +1058,8 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
1042
1058
  // itself.
1043
1059
  detached: true,
1044
1060
  });
1061
+ if (gitDelivery.session)
1062
+ setGitCredentialSessionPid(gitDelivery.session, proc.pid);
1045
1063
  // Track active process by message ID and thread ID
1046
1064
  activeProcesses.set(msg.id, proc);
1047
1065
  activeProcessesByThread.set(msg.thread_id, proc);
@@ -1467,10 +1485,11 @@ export async function spawnClaudeCode(msg, sessionLog, selectedDirectory, sessio
1467
1485
  // the main working dir can't trample in-flight writes or pre-push commits.
1468
1486
  // See teamvibe.ai#120. cwd remains the main brain path on fallback / failure.
1469
1487
  let effectiveCwd = cwd;
1488
+ const gitDelivery = { instance: createSessionInstance() };
1470
1489
  let worktreeOwner;
1471
1490
  if (shouldIsolateInWorktree(msg)) {
1472
1491
  try {
1473
- effectiveCwd = await createWorktree(cwd, msg.thread_id);
1492
+ effectiveCwd = await createWorktree(cwd, msg.thread_id, 'main', gitDelivery.instance);
1474
1493
  worktreeOwner = { brainPath: cwd, id: msg.thread_id };
1475
1494
  sessionLog.info(`[Worktree] Spawned session running in ${effectiveCwd}`);
1476
1495
  }
@@ -1487,12 +1506,12 @@ export async function spawnClaudeCode(msg, sessionLog, selectedDirectory, sessio
1487
1506
  const { randomUUID } = await import('crypto');
1488
1507
  effectiveSessionId = randomUUID();
1489
1508
  }
1490
- const result = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, effectiveSessionId, isFirstMessage, lastMessageTs, onMessageSent, typingIndicator);
1509
+ const result = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, effectiveSessionId, isFirstMessage, lastMessageTs, onMessageSent, typingIndicator, undefined, gitDelivery);
1491
1510
  // If --resume failed, retry as a fresh session (session files may have been lost on container restart)
1492
1511
  let retryResult = result;
1493
1512
  if (!result.success && !result.quota && effectiveSessionId && !isFirstMessage) {
1494
1513
  sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
1495
- retryResult = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, effectiveSessionId, true, lastMessageTs, onMessageSent, typingIndicator);
1514
+ retryResult = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, effectiveSessionId, true, lastMessageTs, onMessageSent, typingIndicator, undefined, gitDelivery);
1496
1515
  }
1497
1516
  // If session ID is "already in use" (stale lock file), retry immediately with a
1498
1517
  // fresh ID — no zombie kill first. Zombie cleanup is #73's concern, not spawn
@@ -1501,17 +1520,23 @@ export async function spawnClaudeCode(msg, sessionLog, selectedDirectory, sessio
1501
1520
  sessionLog.info('Session ID already in use (stale lock), retrying with fresh session ID');
1502
1521
  const { randomUUID } = await import('crypto');
1503
1522
  const freshId = randomUUID();
1504
- const freshResult = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, freshId, true, lastMessageTs, onMessageSent, typingIndicator);
1523
+ const freshResult = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, freshId, true, lastMessageTs, onMessageSent, typingIndicator, undefined, gitDelivery);
1505
1524
  return finalizeSpawnResult(freshResult, true, freshId);
1506
1525
  }
1507
1526
  return finalizeSpawnResult(retryResult, isFirstMessage, effectiveSessionId);
1508
1527
  }
1509
1528
  finally {
1510
- if (worktreeOwner) {
1511
- removeWorktree(worktreeOwner.brainPath, worktreeOwner.id).catch((err) => {
1512
- sessionLog.warn(`[Worktree] Cleanup failed (will be retried by orphan janitor): ${err instanceof Error ? err.message : err}`);
1513
- });
1514
- }
1529
+ const cleanup = () => {
1530
+ if (worktreeOwner) {
1531
+ removeWorktree(worktreeOwner.brainPath, worktreeOwner.id, gitDelivery.instance).catch((err) => {
1532
+ sessionLog.warn(`[Worktree] Cleanup failed (will be retried by orphan janitor): ${err instanceof Error ? err.message : err}`);
1533
+ });
1534
+ }
1535
+ };
1536
+ if (gitDelivery.session)
1537
+ finishGitCredentialSession(gitDelivery.session, cleanup);
1538
+ else
1539
+ cleanup();
1515
1540
  }
1516
1541
  }
1517
1542
  // Configured-pool orchestration is deliberately separate from legacy spawns.
@@ -1543,6 +1568,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1543
1568
  let effectiveCwd = cwd;
1544
1569
  let retainWorktree = resumePending;
1545
1570
  const poolAttempts = poolRuntime?.attempts?.slice(-1000) ?? [];
1571
+ const gitDelivery = { instance: createSessionInstance() };
1546
1572
  let worktreeOwner;
1547
1573
  if (isolate) {
1548
1574
  try {
@@ -1550,7 +1576,8 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1550
1576
  if (resumePending && !existsSync(derivedCwd)) {
1551
1577
  sessionLog.warn(`[Worktree] Pinned worktree missing; recreating ${derivedCwd} for pending session ${sessionId ?? 'unknown'}`);
1552
1578
  }
1553
- effectiveCwd = resumePending && existsSync(derivedCwd) ? derivedCwd : await createWorktree(cwd, msg.thread_id);
1579
+ effectiveCwd = resumePending && existsSync(derivedCwd) ? derivedCwd : await createWorktree(cwd, msg.thread_id, 'main', gitDelivery.instance);
1580
+ writeSessionOwner(join(effectiveCwd, '.teamvibe-session-owner.json'), gitDelivery.instance);
1554
1581
  worktreeOwner = { brainPath: cwd, id: msg.thread_id };
1555
1582
  sessionLog.info(`[Worktree] Spawned session running in ${effectiveCwd}`);
1556
1583
  }
@@ -1621,7 +1648,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1621
1648
  // fallback must report the fresh attempt's delivery flags independently.
1622
1649
  if (!runtime?.resumePending)
1623
1650
  sawSendMessageCall = false;
1624
- const spawned = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime);
1651
+ const spawned = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime, gitDelivery);
1625
1652
  const result = prepared?.account
1626
1653
  ? { ...spawned, quota: quotaForModel(spawned.quota), success: rejectedForModel(spawned.rateLimitInfo) ? false : spawned.success } : spawned;
1627
1654
  sawSendMessageCall ||= result.sawSendMessageCall === true;
@@ -1766,11 +1793,17 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1766
1793
  throw error;
1767
1794
  }
1768
1795
  finally {
1769
- if (worktreeOwner && !retainWorktree) {
1770
- removeWorktree(worktreeOwner.brainPath, worktreeOwner.id).catch((err) => {
1771
- sessionLog.warn(`[Worktree] Cleanup failed (will be retried by orphan janitor): ${err instanceof Error ? err.message : err}`);
1772
- });
1773
- }
1796
+ const cleanup = () => {
1797
+ if (worktreeOwner && !retainWorktree) {
1798
+ removeWorktree(worktreeOwner.brainPath, worktreeOwner.id, gitDelivery.instance).catch((err) => {
1799
+ sessionLog.warn(`[Worktree] Cleanup failed (will be retried by orphan janitor): ${err instanceof Error ? err.message : err}`);
1800
+ });
1801
+ }
1802
+ };
1803
+ if (gitDelivery.session)
1804
+ finishGitCredentialSession(gitDelivery.session, cleanup);
1805
+ else
1806
+ cleanup();
1774
1807
  }
1775
1808
  }
1776
1809
  const activeProcesses = new Map();