chore(agent): agencyConfig contract + git-over-RPC backend (#15542)

*  feat(agent): agencyConfig contract — workingDirByDevice + executionTarget

Type-only contract for the unified per-device working-directory work. Adds
`workingDirByDevice` (per-device cwd) and `executionTarget` to agencyConfig.
No runtime logic consumes them yet — the server/client wiring lands in the UI
PR so it can be validated as one unit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(agent): device gitInfo over RPC + shared local-file-shell git impl

Backend/RPC capability for "git branch / changes / PR for remote devices".
Dormant — no client caller yet; merging changes no existing behavior.

- `@lobechat/local-file-shell/git`: repoType + branch / linked-PR / working-tree
  / ahead-behind + `gitInfo` aggregate + `DeviceGitInfo` type (desktop + CLI).
- desktop `GitCtr.gitInfo()` (@IpcMethod) delegates to it; registered in
  GatewayConnectionCtr's RPC dispatch. `utils/git` re-exports the helpers.
- server: `deviceGateway.gitInfo()` wrapper + `device.gitInfo` TRPC query.
- `@lobechat/types`: `DeviceGitInfo` shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(desktop): fix stale mocks after git impl moved to local-file-shell

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(server): extract DeviceGateway into its own service dir

deviceGateway is a device-scoped gateway client (status/list/tool-call/git/
workspace RPC), not tool-execution-specific. Move it out of toolExecution/
into its own services/deviceGateway/ and update all import sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-06-08 18:09:09 +08:00
committed by GitHub
parent ee65cf2a0f
commit 235a16fc11
35 changed files with 522 additions and 271 deletions
@@ -29,6 +29,7 @@ import { type ILocalSystemService, LocalSystemExecutionRuntime } from '@lobechat
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import ImessageBridgeService from '@/services/imessageBridgeSrv';
import GitCtr from './GitCtr';
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
import { ControllerModule, IpcMethod } from './index';
import LocalFileCtr from './LocalFileCtr';
@@ -168,6 +169,10 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.app.getController(WorkspaceCtr);
}
private get gitCtr() {
return this.app.getController(GitCtr);
}
private get shellCommandCtr() {
return this.app.getController(ShellCommandCtr);
}
@@ -356,6 +361,10 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.workspaceCtr.initWorkspace(params as InitWorkspaceParams);
}
case 'gitInfo': {
return this.gitCtr.gitInfo(params as { isGithub?: boolean; scope: string });
}
default: {
throw new Error(`Unknown device RPC method: ${method}`);
}
+22 -165
View File
@@ -22,8 +22,16 @@ import type {
GitWorkingTreeStatus,
SubmoduleWorkingTreePatches,
} from '@lobechat/electron-client-ipc';
import {
type DeviceGitInfo,
getGitAheadBehind as computeGitAheadBehind,
getGitBranch as computeGitBranch,
getGitWorkingTreeStatus as computeGitWorkingTreeStatus,
getLinkedPullRequest as computeLinkedPullRequest,
gitInfo as computeGitInfo,
} from '@lobechat/local-file-shell';
import { detectRepoType, resolveGitDir } from '@/utils/git';
import { detectRepoType } from '@/utils/git';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
@@ -450,23 +458,17 @@ export default class GitController extends ControllerModule {
*/
@IpcMethod()
async getGitBranch(dirPath: string): Promise<GitBranchInfo> {
try {
const gitDir = await resolveGitDir(dirPath);
if (!gitDir) return {};
return computeGitBranch(dirPath);
}
const head = (await readFile(path.join(gitDir, 'HEAD'), 'utf8')).trim();
const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
if (refMatch) {
return { branch: refMatch[1] };
}
// Detached HEAD — HEAD file contains the full sha
if (/^[\da-f]{40}$/i.test(head)) {
return { branch: head.slice(0, 7), detached: true };
}
return {};
} catch {
return {};
}
/**
* Aggregate git status (branch + linked PR + working tree + ahead/behind) for a
* directory. The single entry point shared by the local desktop display, the
* device `gitInfo` RPC, and the CLI — implemented in `@lobechat/local-file-shell`.
*/
@IpcMethod()
async gitInfo(params: { isGithub?: boolean; scope: string }): Promise<DeviceGitInfo> {
return computeGitInfo(params);
}
/**
@@ -479,58 +481,7 @@ export default class GitController extends ControllerModule {
branch: string;
path: string;
}): Promise<GitLinkedPullRequestResult> {
const { path: dirPath, branch } = payload;
if (!branch) {
return { pullRequest: null, status: 'ok' };
}
const execFileAsync = promisify(execFile);
try {
const { stdout } = await execFileAsync(
'gh',
[
'pr',
'list',
'--head',
branch,
'--state',
'open',
'--limit',
'5',
'--json',
'number,url,title,state',
],
{ cwd: dirPath, timeout: 8000 },
);
const parsed = JSON.parse(stdout.trim() || '[]') as Array<{
number: number;
state: string;
title: string;
url: string;
}>;
if (parsed.length === 0) {
return { pullRequest: null, status: 'ok' };
}
const [primary, ...rest] = parsed;
return {
extraCount: rest.length,
pullRequest: primary,
status: 'ok',
};
} catch (error: any) {
const code = error?.code;
const stderr: string = error?.stderr ?? '';
// `gh` binary not on PATH
if (code === 'ENOENT') {
return { pullRequest: null, status: 'gh-missing' };
}
// gh reports auth issues via stderr; treat as a soft-fail
if (/auth\s+login|not\s+logged\s+in|authentication/i.test(stderr)) {
return { pullRequest: null, status: 'gh-missing' };
}
logger.debug('[getLinkedPullRequest] failed', { branch, code, stderr });
return { pullRequest: null, status: 'error' };
}
return computeLinkedPullRequest(payload);
}
/**
@@ -635,42 +586,7 @@ export default class GitController extends ControllerModule {
*/
@IpcMethod()
async getGitWorkingTreeStatus(dirPath: string): Promise<GitWorkingTreeStatus> {
const execFileAsync = promisify(execFile);
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-u', '-z'], {
cwd: dirPath,
timeout: 5000,
});
const tokens = stdout.split('\0');
let added = 0;
let modified = 0;
let deleted = 0;
let i = 0;
while (i < tokens.length) {
const entry = tokens[i];
i++;
if (entry.length < 2) continue;
const x = entry[0];
const y = entry[1];
// R/C entries carry an extra source-path token we must consume.
if (x === 'R' || x === 'C') i++;
if (x === '?' && y === '?') {
added++;
} else if (x === '!' && y === '!') {
// ignored — skip
} else if (x === 'D' || y === 'D') {
deleted++;
} else if (x === 'A' || y === 'A') {
added++;
} else {
modified++;
}
}
const total = added + modified + deleted;
return { added, clean: total === 0, deleted, modified, total };
} catch {
return { added: 0, clean: true, deleted: 0, modified: 0, total: 0 };
}
return computeGitWorkingTreeStatus(dirPath);
}
/**
@@ -1133,66 +1049,7 @@ export default class GitController extends ControllerModule {
*/
@IpcMethod()
async getGitAheadBehind(dirPath: string): Promise<GitAheadBehind> {
const execFileAsync = promisify(execFile);
try {
await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], {
cwd: dirPath,
timeout: 10_000,
});
} catch {
// swallow — fall through to compute against cached refs
}
try {
const { stdout: upstreamOut } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
{ cwd: dirPath, timeout: 5000 },
);
const upstream = upstreamOut.trim();
if (!upstream) return { ahead: 0, behind: 0, hasUpstream: false };
const { stdout } = await execFileAsync(
'git',
['rev-list', '--left-right', '--count', `${upstream}...HEAD`],
{ cwd: dirPath, timeout: 5000 },
);
const [behindStr, aheadStr] = stdout.trim().split(/\s+/);
const behind = Number.parseInt(behindStr ?? '0', 10) || 0;
const ahead = Number.parseInt(aheadStr ?? '0', 10) || 0;
// `git push -u origin HEAD` always targets origin/<current-branch-name>,
// which may differ from upstream (the branched-off-canary case).
let pushTarget: string | undefined;
let pushTargetExists = false;
try {
const { stdout: branchOut } = await execFileAsync(
'git',
['symbolic-ref', '--short', 'HEAD'],
{ cwd: dirPath, timeout: 5000 },
);
const branch = branchOut.trim();
if (branch) {
pushTarget = `origin/${branch}`;
try {
await execFileAsync(
'git',
['rev-parse', '--verify', '--quiet', `refs/remotes/${pushTarget}`],
{ cwd: dirPath, timeout: 5000 },
);
pushTargetExists = true;
} catch {
pushTargetExists = false;
}
}
} catch {
// detached HEAD — leave pushTarget undefined
}
return { ahead, behind, hasUpstream: true, pushTarget, pushTargetExists, upstream };
} catch {
// No upstream configured, detached HEAD, or git error — all treated as "no upstream"
return { ahead: 0, behind: 0, hasUpstream: false };
}
return computeGitAheadBehind(dirPath);
}
/**
@@ -26,6 +26,7 @@ vi.mock('@/utils/logger', () => ({
// Mock child_process for the shared package
vi.mock('node:child_process', () => ({
execFile: vi.fn(),
spawn: vi.fn(),
}));
@@ -1,15 +1,11 @@
import { readFile } from 'node:fs/promises';
import { readdir, readFile } from 'node:fs/promises';
import { readdir } from 'fs-extra';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { detectRepoType } from '../git';
vi.mock('node:fs/promises', () => ({
readFile: vi.fn(),
}));
vi.mock('fs-extra', () => ({
readdir: vi.fn(),
}));
+4 -69
View File
@@ -1,71 +1,6 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { readdir } from 'fs-extra';
/**
* Resolve the actual `.git` directory for a working tree.
* Supports both standard layouts and worktree pointer files (`.git` as a regular file
* containing `gitdir: <path>`).
* Git repo-type / gitdir helpers. The implementations now live in
* `@lobechat/local-file-shell` so desktop, the device RPC, and the CLI share one
* copy; re-exported here to keep existing `@/utils/git` import sites stable.
*/
export const resolveGitDir = async (dirPath: string): Promise<string | undefined> => {
const gitPath = path.join(dirPath, '.git');
try {
const content = await readFile(gitPath, 'utf8');
const worktreeMatch = /^gitdir:\s*(\S.*)$/m.exec(content.trim());
if (worktreeMatch) {
const resolved = worktreeMatch[1].trim();
return path.isAbsolute(resolved) ? resolved : path.resolve(dirPath, resolved);
}
} catch {
// `.git` is a directory (EISDIR) or missing — fall through
}
try {
const stat = await readdir(gitPath);
if (stat.length > 0) return gitPath;
} catch {
return undefined;
}
return undefined;
};
/**
* Resolve the common git dir — where shared state like `config` and
* `packed-refs` lives. For linked worktrees, `resolveGitDir` returns
* `.git/worktrees/<name>/` which has its own `HEAD` but no `config`;
* the `commondir` pointer inside it resolves to the main repo's gitdir.
*/
export const resolveCommonGitDir = async (dirPath: string): Promise<string | undefined> => {
const gitDir = await resolveGitDir(dirPath);
if (!gitDir) return undefined;
try {
const commondir = (await readFile(path.join(gitDir, 'commondir'), 'utf8')).trim();
if (!commondir) return gitDir;
return path.isAbsolute(commondir) ? commondir : path.resolve(gitDir, commondir);
} catch {
return gitDir;
}
};
// Match `github.com` only in a remote-URL host position: preceded by `@`, `/`,
// or line start (covers `git@github.com:`, `https://github.com/`,
// `ssh://git@github.com/`, etc.) and followed by `:` or `/`. Avoids matching
// look-alikes like `evilgithub.com` or `github.com.attacker.com`.
const GITHUB_REMOTE_HOST_RE = /(?:^|[@/])github\.com[:/]/m;
/**
* Classify a working tree as `git` (plain) / `github` (origin points at github.com) /
* `undefined` (not a git repo). Reads the shared gitdir's `config` so submodules and
* linked worktrees resolve the same as the main repo.
*/
export const detectRepoType = async (dirPath: string): Promise<'git' | 'github' | undefined> => {
const commonDir = await resolveCommonGitDir(dirPath);
if (!commonDir) return undefined;
try {
const config = await readFile(path.join(commonDir, 'config'), 'utf8');
if (GITHUB_REMOTE_HOST_RE.test(config)) return 'github';
return 'git';
} catch {
return undefined;
}
};
export { detectRepoType, resolveCommonGitDir, resolveGitDir } from '@lobechat/local-file-shell';
@@ -0,0 +1,3 @@
export * from './info';
export * from './repoType';
export * from './types';
+219
View File
@@ -0,0 +1,219 @@
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { promisify } from 'node:util';
import { createLogger } from '../logger';
import { resolveGitDir } from './repoType';
import type {
DeviceGitInfo,
GitAheadBehind,
GitBranchInfo,
GitLinkedPullRequestResult,
GitWorkingTreeStatus,
} from './types';
const log = createLogger('local-file-shell:git');
const execFileAsync = promisify(execFile);
/** Current branch short name, or short SHA + `detached` for detached HEAD. */
export const getGitBranch = async (dirPath: string): Promise<GitBranchInfo> => {
try {
const gitDir = await resolveGitDir(dirPath);
if (!gitDir) return {};
const head = (await readFile(`${gitDir}/HEAD`, 'utf8')).trim();
const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
if (refMatch) return { branch: refMatch[1] };
// Detached HEAD — HEAD file contains the full sha
if (/^[\da-f]{40}$/i.test(head)) return { branch: head.slice(0, 7), detached: true };
return {};
} catch {
return {};
}
};
/**
* Query `gh` CLI for an open pull request whose head branch matches `branch`.
* Returns `status: 'gh-missing'` when `gh` is unavailable / not authed.
*/
export const getLinkedPullRequest = async (payload: {
branch: string;
path: string;
}): Promise<GitLinkedPullRequestResult> => {
const { path: dirPath, branch } = payload;
if (!branch) return { pullRequest: null, status: 'ok' };
try {
const { stdout } = await execFileAsync(
'gh',
[
'pr',
'list',
'--head',
branch,
'--state',
'open',
'--limit',
'5',
'--json',
'number,url,title,state',
],
{ cwd: dirPath, timeout: 8000 },
);
const parsed = JSON.parse(stdout.trim() || '[]') as Array<{
number: number;
state: string;
title: string;
url: string;
}>;
if (parsed.length === 0) return { pullRequest: null, status: 'ok' };
const [primary, ...rest] = parsed;
return { extraCount: rest.length, pullRequest: primary, status: 'ok' };
} catch (error: any) {
const code = error?.code;
const stderr: string = error?.stderr ?? '';
if (code === 'ENOENT') return { pullRequest: null, status: 'gh-missing' };
if (/auth\s+login|not\s+logged\s+in|authentication/i.test(stderr)) {
return { pullRequest: null, status: 'gh-missing' };
}
log.debug('[getLinkedPullRequest] failed', { branch, code, stderr });
return { pullRequest: null, status: 'error' };
}
};
/** Bucket dirty files into added / modified / deleted via `git status --porcelain -z`. */
export const getGitWorkingTreeStatus = async (dirPath: string): Promise<GitWorkingTreeStatus> => {
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-u', '-z'], {
cwd: dirPath,
timeout: 5000,
});
const tokens = stdout.split('\0');
let added = 0;
let modified = 0;
let deleted = 0;
let i = 0;
while (i < tokens.length) {
const entry = tokens[i];
i++;
if (entry.length < 2) continue;
const x = entry[0];
const y = entry[1];
// R/C entries carry an extra source-path token we must consume.
if (x === 'R' || x === 'C') i++;
if (x === '?' && y === '?') {
added++;
} else if (x === '!' && y === '!') {
// ignored — skip
} else if (x === 'D' || y === 'D') {
deleted++;
} else if (x === 'A' || y === 'A') {
added++;
} else {
modified++;
}
}
const total = added + modified + deleted;
return { added, clean: total === 0, deleted, modified, total };
} catch {
return { added: 0, clean: true, deleted: 0, modified: 0, total: 0 };
}
};
/**
* Count commits HEAD is ahead/behind its upstream. Does a best-effort `git fetch`
* first; swallows fetch failures (offline / no creds) and computes against cached
* refs. Returns `hasUpstream: false` when no upstream is configured.
*/
export const getGitAheadBehind = async (dirPath: string): Promise<GitAheadBehind> => {
try {
await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], {
cwd: dirPath,
timeout: 10_000,
});
} catch {
// swallow — fall through to compute against cached refs
}
try {
const { stdout: upstreamOut } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
{ cwd: dirPath, timeout: 5000 },
);
const upstream = upstreamOut.trim();
if (!upstream) return { ahead: 0, behind: 0, hasUpstream: false };
const { stdout } = await execFileAsync(
'git',
['rev-list', '--left-right', '--count', `${upstream}...HEAD`],
{ cwd: dirPath, timeout: 5000 },
);
const [behindStr, aheadStr] = stdout.trim().split(/\s+/);
const behind = Number.parseInt(behindStr ?? '0', 10) || 0;
const ahead = Number.parseInt(aheadStr ?? '0', 10) || 0;
// `git push -u origin HEAD` always targets origin/<current-branch-name>,
// which may differ from upstream (the branched-off-canary case).
let pushTarget: string | undefined;
let pushTargetExists = false;
try {
const { stdout: branchOut } = await execFileAsync(
'git',
['symbolic-ref', '--short', 'HEAD'],
{ cwd: dirPath, timeout: 5000 },
);
const branch = branchOut.trim();
if (branch) {
pushTarget = `origin/${branch}`;
try {
await execFileAsync(
'git',
['rev-parse', '--verify', '--quiet', `refs/remotes/${pushTarget}`],
{ cwd: dirPath, timeout: 5000 },
);
pushTargetExists = true;
} catch {
pushTargetExists = false;
}
}
} catch {
// detached HEAD — leave pushTarget undefined
}
return { ahead, behind, hasUpstream: true, pushTarget, pushTargetExists, upstream };
} catch {
return { ahead: 0, behind: 0, hasUpstream: false };
}
};
/**
* Aggregate git status (branch + linked PR + working tree + ahead/behind) into one
* payload. The single source behind the desktop display, the device `gitInfo` RPC,
* and the CLI. PR lookup runs only for a real branch on a GitHub remote.
*/
export const gitInfo = async (params: {
isGithub?: boolean;
scope: string;
}): Promise<DeviceGitInfo> => {
const dirPath = params.scope;
const { branch, detached } = await getGitBranch(dirPath);
let info: DeviceGitInfo['info'] = { branch, detached };
if (branch && !detached && params.isGithub) {
const pr = await getLinkedPullRequest({ branch, path: dirPath });
info = {
branch,
detached,
extraCount: pr.extraCount,
ghMissing: pr.status === 'gh-missing',
pullRequest: pr.pullRequest,
};
}
const [workingStatus, aheadBehind] = await Promise.all([
getGitWorkingTreeStatus(dirPath),
getGitAheadBehind(dirPath),
]);
return { aheadBehind, info, workingStatus };
};
@@ -0,0 +1,68 @@
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
/**
* Resolve the actual `.git` directory for a working tree. Supports both standard
* layouts and worktree pointer files (`.git` as a regular file containing
* `gitdir: <path>`).
*/
export const resolveGitDir = async (dirPath: string): Promise<string | undefined> => {
const gitPath = path.join(dirPath, '.git');
try {
const content = await readFile(gitPath, 'utf8');
const worktreeMatch = /^gitdir:\s*(\S.*)$/m.exec(content.trim());
if (worktreeMatch) {
const resolved = worktreeMatch[1].trim();
return path.isAbsolute(resolved) ? resolved : path.resolve(dirPath, resolved);
}
} catch {
// `.git` is a directory (EISDIR) or missing — fall through
}
try {
const entries = await readdir(gitPath);
if (entries.length > 0) return gitPath;
} catch {
return undefined;
}
return undefined;
};
/**
* Resolve the common git dir — where shared state like `config` and `packed-refs`
* lives. For linked worktrees, `resolveGitDir` returns `.git/worktrees/<name>/`
* which has its own `HEAD` but no `config`; the `commondir` pointer inside it
* resolves to the main repo's gitdir.
*/
export const resolveCommonGitDir = async (dirPath: string): Promise<string | undefined> => {
const gitDir = await resolveGitDir(dirPath);
if (!gitDir) return undefined;
try {
const commondir = (await readFile(path.join(gitDir, 'commondir'), 'utf8')).trim();
if (!commondir) return gitDir;
return path.isAbsolute(commondir) ? commondir : path.resolve(gitDir, commondir);
} catch {
return gitDir;
}
};
// Match `github.com` only in a remote-URL host position: preceded by `@`, `/`, or
// line start (covers `git@github.com:`, `https://github.com/`, `ssh://git@github.com/`)
// and followed by `:` or `/`. Avoids matching look-alikes like `evilgithub.com`.
const GITHUB_REMOTE_HOST_RE = /(?:^|[@/])github\.com[:/]/m;
/**
* Classify a working tree as `git` (plain) / `github` (origin points at github.com)
* / `undefined` (not a git repo). Reads the shared gitdir's `config` so submodules
* and linked worktrees resolve the same as the main repo.
*/
export const detectRepoType = async (dirPath: string): Promise<'git' | 'github' | undefined> => {
const commonDir = await resolveCommonGitDir(dirPath);
if (!commonDir) return undefined;
try {
const config = await readFile(path.join(commonDir, 'config'), 'utf8');
if (GITHUB_REMOTE_HOST_RE.test(config)) return 'github';
return 'git';
} catch {
return undefined;
}
};
@@ -0,0 +1,55 @@
/** Branch short name (or short SHA when detached). */
export interface GitBranchInfo {
branch?: string;
detached?: boolean;
}
export interface GitLinkedPullRequest {
number: number;
state: string;
title: string;
url: string;
}
export interface GitLinkedPullRequestResult {
/** Additional open PRs targeting the same head branch, beyond the primary one. */
extraCount?: number;
/** Null when no open PR is linked to the branch. */
pullRequest: GitLinkedPullRequest | null;
/** 'ok' — succeeded; 'gh-missing' — gh CLI unavailable / not authed; 'error' — other. */
status: 'ok' | 'gh-missing' | 'error';
}
export interface GitWorkingTreeStatus {
added: number;
clean: boolean;
deleted: number;
modified: number;
total: number;
}
export interface GitAheadBehind {
ahead: number;
behind: number;
hasUpstream: boolean;
pushTarget?: string;
pushTargetExists?: boolean;
upstream?: string;
}
/**
* Aggregate git status for a working directory — the single payload behind both
* the desktop git display and the device `gitInfo` RPC (and CLI). Mirrors the
* three renderer hooks (`useGitInfo` / `useWorkingTreeStatus` / `useGitAheadBehind`).
*/
export interface DeviceGitInfo {
aheadBehind: GitAheadBehind;
info: {
branch?: string;
detached?: boolean;
extraCount?: number;
ghMissing?: boolean;
pullRequest?: GitLinkedPullRequest | null;
};
workingStatus: GitWorkingTreeStatus;
}
+1
View File
@@ -1,6 +1,7 @@
export * from './contentSearch';
export * from './file';
export * from './fileSearch';
export * from './git';
export { createLogger, type Logger, type LoggerFactory, setLoggerFactory } from './logger';
export * from './shell';
export type { ToolCategory, ToolDetector } from './toolDetector';
+14
View File
@@ -75,4 +75,18 @@ export interface LobeAgentAgencyConfig {
* any `verifyCriteriaIds` — into its check plan. References `verify_rubrics.id`.
*/
verifyRubricId?: string;
/**
* Per-device working directory chosen for this agent. Key = `deviceId` (the
* local machine uses its own gateway deviceId, so local and remote share one
* model). This is the **agent-level** cwd in the resolution precedence:
*
* `topic.metadata.workingDirectory`
* > `workingDirByDevice[targetDeviceId]`
* > `device.defaultCwd`
*
* Keyed per device so switching the bound device never resolves a path that
* only exists on another machine. Persisted (server-synced) so the choice
* follows the user across sessions / ends.
*/
workingDirByDevice?: Record<string, string>;
}
@@ -146,6 +146,40 @@ export interface WorkspaceInitResult {
skills: ProjectSkillMeta[];
}
/**
* Git status of a device's working directory, returned by the `gitInfo` device
* RPC so a remote device (or web client) can render branch / file changes / PR
* the same way the local desktop does. Field shapes mirror the desktop git
* service so the UI consumes both paths interchangeably.
*/
export interface DeviceGitInfo {
/** Commit divergence vs the upstream tracking ref. */
aheadBehind: {
ahead: number;
behind: number;
hasUpstream: boolean;
pushTarget?: string;
pushTargetExists?: boolean;
upstream?: string;
};
/** Branch name + linked GitHub pull request (when the repo is a GitHub remote). */
info: {
branch?: string;
detached?: boolean;
extraCount?: number;
ghMissing?: boolean;
pullRequest?: { number: number; state: string; title: string; url: string } | null;
};
/** Working-tree dirty-file counts. */
workingStatus: {
added: number;
clean: boolean;
deleted: number;
modified: number;
total: number;
};
}
/**
* Parameters for execAgent - execute a single Agent
* Either agentId or slug must be provided
+25 -1
View File
@@ -5,7 +5,7 @@ import { z } from 'zod';
import { DeviceModel } from '@/database/models/device';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { deviceGateway } from '@/server/services/toolExecution/deviceGateway';
import { deviceGateway } from '@/server/services/deviceGateway';
import { preserveWorkspaceCache } from './deviceWorkingDirs';
@@ -76,6 +76,30 @@ export const deviceRouter = router({
}
}),
/**
* Git status (branch / file changes / linked PR) for a directory on a remote
* device, fetched via the device's `gitInfo` RPC. Lets the UI render a remote
* device's git the same as the local desktop. Returns `null` when offline /
* the directory isn't a git repo.
*/
gitInfo: deviceProcedure
.input(
z.object({
deviceId: z.string(),
isGithub: z.boolean().optional(),
scope: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const result = await deviceGateway.gitInfo(
ctx.userId,
input.deviceId,
input.scope,
input.isGithub,
);
return result ?? null;
}),
/**
* Fetch the agent profile (title, description, avatar) from the platform
* installed on the given device. Used to pre-fill the creation modal.
@@ -162,7 +162,7 @@ vi.mock('@/server/modules/Mecha', () => ({
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
}));
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
vi.mock('@/server/services/deviceGateway', () => ({
deviceGateway: {
isConfigured: false,
queryDeviceList: vi.fn().mockResolvedValue([]),
@@ -118,7 +118,7 @@ vi.mock('@/server/modules/Mecha', () => ({
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
}));
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
vi.mock('@/server/services/deviceGateway', () => ({
deviceGateway: mockDeviceProxy,
}));
@@ -126,7 +126,7 @@ vi.mock('@/server/modules/Mecha', () => {
};
});
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
vi.mock('@/server/services/deviceGateway', () => ({
deviceGateway: {
get isConfigured() {
// Will be overridden per-test via vi.spyOn or re-mock
@@ -209,7 +209,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
describe('deviceContext forwarded to createServerAgentToolsEngine', () => {
it('should pass deviceContext when gateway is configured', async () => {
// Override deviceGateway.isConfigured
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
const { deviceGateway } = await import('@/server/services/deviceGateway');
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(true);
mockQueryDeviceList.mockResolvedValue([
{ deviceId: 'dev-1', deviceName: 'My PC', platform: 'win32' },
@@ -230,7 +230,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
});
it('should not pass deviceContext when gateway is not configured', async () => {
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
const { deviceGateway } = await import('@/server/services/deviceGateway');
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(false);
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
@@ -245,7 +245,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
describe('RemoteDevice systemRole override', () => {
it('should override RemoteDevice systemRole with dynamic prompt when enabled by ToolsEngine', async () => {
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
const { deviceGateway } = await import('@/server/services/deviceGateway');
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(true);
mockQueryDeviceList.mockResolvedValue([
{ deviceId: 'dev-1', deviceName: 'My PC', platform: 'win32' },
@@ -279,7 +279,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
});
it('should NOT have RemoteDevice in manifestMap when gateway is not configured', async () => {
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
const { deviceGateway } = await import('@/server/services/deviceGateway');
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(false);
// ToolsEngine returns empty manifestMap (RemoteDevice disabled by enableChecker)
@@ -301,7 +301,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
describe('toolExecutorMap gating on gatewayConfigured (regression for #13769)', () => {
it('should mark local-system as client when gateway is NOT configured (standalone Electron)', async () => {
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
const { deviceGateway } = await import('@/server/services/deviceGateway');
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(false);
mockGetEnabledPluginManifests.mockReturnValue(
@@ -316,7 +316,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
});
it('should NOT mark local-system as client when gateway IS configured (cloud)', async () => {
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
const { deviceGateway } = await import('@/server/services/deviceGateway');
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(true);
mockQueryDeviceList.mockResolvedValue([
{ deviceId: 'dev-1', deviceName: 'My PC', platform: 'win32' },
@@ -348,7 +348,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
mockGetEnabledPluginManifests.mockReturnValue(new Map([['my-stdio-mcp', stdioManifest]]));
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig({ plugins: ['my-stdio-mcp'] }));
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
const { deviceGateway } = await import('@/server/services/deviceGateway');
// Gateway NOT configured → should mark as client
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(false);
@@ -374,7 +374,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
// Remote Device proxy to the device registered with the gateway, never
// back to the caller. (The Phase 6.4 clientRuntime=desktop
// short-circuit that bypassed this gate was removed.)
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
const { deviceGateway } = await import('@/server/services/deviceGateway');
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(true);
mockQueryDeviceList.mockResolvedValue([
{ deviceId: 'dev-1', deviceName: 'Remote VM', platform: 'linux' },
@@ -402,7 +402,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
meta: { title: 'Stdio' },
};
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
const { deviceGateway } = await import('@/server/services/deviceGateway');
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(true);
mockQueryDeviceList.mockResolvedValue([
{ deviceId: 'dev-1', deviceName: 'Remote VM', platform: 'linux' },
@@ -114,7 +114,7 @@ vi.mock('@/server/modules/Mecha', () => ({
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
}));
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
vi.mock('@/server/services/deviceGateway', () => ({
deviceGateway: {
isConfigured: false,
queryDeviceList: vi.fn().mockResolvedValue([]),
@@ -137,7 +137,7 @@ vi.mock('@/server/modules/Mecha', () => ({
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
}));
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
vi.mock('@/server/services/deviceGateway', () => ({
deviceGateway: {
isConfigured: false,
queryDeviceList: vi.fn().mockResolvedValue([]),
@@ -88,7 +88,7 @@ vi.mock('@/server/modules/Mecha', () => ({
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
}));
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
vi.mock('@/server/services/deviceGateway', () => ({
deviceGateway: {
isConfigured: false,
queryDeviceList: vi.fn().mockResolvedValue([]),
@@ -88,7 +88,7 @@ vi.mock('@/server/modules/Mecha', () => ({
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
}));
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
vi.mock('@/server/services/deviceGateway', () => ({
deviceGateway: {
isConfigured: false,
queryDeviceList: vi.fn().mockResolvedValue([]),
@@ -110,7 +110,7 @@ vi.mock('@/server/modules/Mecha', () => ({
}),
}));
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
vi.mock('@/server/services/deviceGateway', () => ({
deviceGateway: {
isConfigured: false,
queryDeviceList: vi.fn().mockResolvedValue([]),
@@ -118,7 +118,7 @@ vi.mock('@/server/modules/Mecha', () => ({
}),
}));
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
vi.mock('@/server/services/deviceGateway', () => ({
deviceGateway: { isConfigured: false, queryDeviceList: vi.fn().mockResolvedValue([]) },
}));
@@ -124,7 +124,7 @@ vi.mock('@/server/modules/Mecha', () => ({
}));
// Mock deviceGateway
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
vi.mock('@/server/services/deviceGateway', () => ({
deviceGateway: {
isConfigured: false,
queryDeviceList: vi.fn().mockResolvedValue([]),
@@ -110,7 +110,7 @@ vi.mock('@/server/services/file', () => ({
})),
}));
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
vi.mock('@/server/services/deviceGateway', () => ({
deviceGateway: {
isConfigured: false,
queryDeviceList: vi.fn().mockResolvedValue([]),
+1 -1
View File
@@ -81,6 +81,7 @@ import {
resolveAgentSelfIterationCapability,
} from '@/server/services/agentSignal/featureGate';
import { shouldSuppressSignal } from '@/server/services/agentSignal/suppressSignal';
import { deviceGateway } from '@/server/services/deviceGateway';
import { DocumentService } from '@/server/services/document';
import { FileService } from '@/server/services/file';
import { resolveAttachmentsByFileIds } from '@/server/services/file/resolveAttachments';
@@ -88,7 +89,6 @@ import { HeterogeneousAgentService } from '@/server/services/heterogeneousAgent'
import type { ConversationHistoryEntry } from '@/server/services/heterogeneousAgent/cloudHeteroContext';
import { KlavisService } from '@/server/services/klavis';
import { MarketService } from '@/server/services/market';
import { deviceGateway } from '@/server/services/toolExecution/deviceGateway';
import { markdownToTxt } from '@/utils/markdownToTxt';
import { resolveDeviceAccessPolicy } from './deviceAccessPolicy';
@@ -4,7 +4,7 @@ import { ImessageClientFactory } from './client';
const mockExecuteMessageApi = vi.hoisted(() => vi.fn());
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
vi.mock('@/server/services/deviceGateway', () => ({
deviceGateway: {
executeMessageApi: mockExecuteMessageApi,
},
@@ -7,7 +7,7 @@ import type {
BlueBubblesSendOptions,
} from '@lobechat/chat-adapter-imessage';
import { deviceGateway } from '@/server/services/toolExecution/deviceGateway';
import { deviceGateway } from '@/server/services/deviceGateway';
const IMESSAGE_MESSAGE_API_TIMEOUT_MS = 60_000;
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
// Import after mocks are set up
import { DeviceGateway } from '../deviceGateway';
import { DeviceGateway } from '../index';
const mockEnv = vi.hoisted(() => ({
DEVICE_GATEWAY_SERVICE_TOKEN: undefined as string | undefined,
@@ -8,7 +8,7 @@ import {
type GatewayMcpStdioParams,
} from '@lobechat/device-gateway-client';
import type { HeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
import type { ProjectSkillMeta, WorkspaceInitResult } from '@lobechat/types';
import type { DeviceGitInfo, ProjectSkillMeta, WorkspaceInitResult } from '@lobechat/types';
import debug from 'debug';
import { gatewayEnv } from '@/envs/gateway';
@@ -127,6 +127,39 @@ export class DeviceGateway {
}
}
/**
* Fetch git status (branch / file changes / PR) for a directory on a remote
* device, via the same generic `invokeRpc` channel as `initWorkspace`. Lets
* the UI render a remote device's git the same as the local desktop.
*/
async gitInfo(
userId: string,
deviceId: string,
scope: string,
isGithub = false,
timeout = 15_000,
): Promise<DeviceGitInfo | undefined> {
const client = this.getClient();
if (!client) return undefined;
try {
const result = await client.invokeRpc<DeviceGitInfo>(
{ deviceId, timeout, userId },
{ method: 'gitInfo', params: { isGithub, scope } },
);
if (!result.success || !result.data) {
log('gitInfo: failed for deviceId=%s — %s', deviceId, result.error);
return undefined;
}
return result.data;
} catch (error) {
log('gitInfo: error for deviceId=%s — %O', deviceId, error);
return undefined;
}
}
async dispatchAgentRun(params: {
agentType: HeterogeneousAgentType;
cwd?: string;
+1 -1
View File
@@ -8,6 +8,7 @@ import {
buildBlockedToolResponse,
getConnectorToolPermission,
} from '@/libs/mcp/connectorPermissionCheck';
import { deviceGateway } from '@/server/services/deviceGateway';
import { contentBlocksToString } from '@/server/services/mcp/contentProcessor';
import {
DEFAULT_TOOL_RESULT_MAX_LENGTH,
@@ -17,7 +18,6 @@ import {
import { DiscoverService } from '../discover';
import { type MCPService } from '../mcp';
import { type BuiltinToolsExecutor } from './builtin';
import { deviceGateway } from './deviceGateway';
import { classifyToolError } from './errorClassification';
import {
type ToolExecutionContext,
@@ -5,7 +5,7 @@ import { type ToolExecutionContext } from '../../types';
// Mock deviceGateway
const mockExecuteToolCall = vi.fn();
vi.mock('../../deviceGateway', () => ({
vi.mock('@/server/services/deviceGateway', () => ({
deviceGateway: {
executeToolCall: (...args: any[]) => mockExecuteToolCall(...args),
},
@@ -8,7 +8,7 @@ import { type ToolExecutionContext } from '../../types';
// Mock deviceGateway
const mockQueryDeviceList = vi.fn();
vi.mock('../../deviceGateway', () => ({
vi.mock('@/server/services/deviceGateway', () => ({
deviceGateway: {
queryDeviceList: (...args: any[]) => mockQueryDeviceList(...args),
},
@@ -1,6 +1,7 @@
import { LocalSystemIdentifier, LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { deviceGateway } from '../deviceGateway';
import { deviceGateway } from '@/server/services/deviceGateway';
import { type ServerRuntimeRegistration } from './types';
export const localSystemRuntime: ServerRuntimeRegistration = {
@@ -3,7 +3,8 @@ import {
RemoteDeviceIdentifier,
} from '@lobechat/builtin-tool-remote-device';
import { deviceGateway } from '../deviceGateway';
import { deviceGateway } from '@/server/services/deviceGateway';
import { type ServerRuntimeRegistration } from './types';
export const remoteDeviceRuntime: ServerRuntimeRegistration = {
@@ -21,13 +21,13 @@ import { FileModel } from '@/database/models/file';
import { UserModel } from '@/database/models/user';
import { filterBuiltinSkills } from '@/helpers/skillFilters';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
import { deviceGateway } from '@/server/services/deviceGateway';
import { FileService } from '@/server/services/file';
import { MarketService } from '@/server/services/market';
import { createSandboxService, normalizeSandboxCommandResult } from '@/server/services/sandbox';
import { SkillResourceService } from '@/server/services/skill/resource';
import { preprocessLhCommand } from '@/server/services/toolExecution/preprocessLhCommand';
import { deviceGateway } from '../deviceGateway';
import { type ServerRuntimeRegistration } from './types';
const log = debug('lobe-server:skills-runtime');