Skip to content

Commit 44fcfc8

Browse files
committed
Merge branch 'easycode-ls-dev' into 'master'
feat(feishu): self-update/restart tool + /feishu restart + --feishu autostart See merge request ai_native/DeepVCode/DeepVcodeClient!486
2 parents 6d19461 + 929d38a commit 44fcfc8

7 files changed

Lines changed: 613 additions & 1 deletion

File tree

packages/cli/src/config/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export interface CliArgs {
8282
testAudio: boolean | undefined;
8383
workdir: string | undefined;
8484
login: string | undefined;
85+
feishu: boolean | undefined;
8586
}
8687

8788
export async function parseArguments(extensions: Extension[] = []): Promise<CliArgs> {
@@ -193,6 +194,11 @@ export async function parseArguments(extensions: Extension[] = []): Promise<CliA
193194
type: 'boolean',
194195
description: 'Starts the agent in ACP mode',
195196
})
197+
.option('feishu', {
198+
type: 'boolean',
199+
description:
200+
'Launch directly into Feishu/Lark gateway mode (auto-runs /feishu start after startup). Used by self-update auto-restart.',
201+
})
196202
.option('experimental-acp', {
197203
type: 'boolean',
198204
description:
@@ -575,6 +581,7 @@ export async function loadCliConfig(
575581
targetDir: process.cwd(),
576582
debugMode,
577583
question: argv.promptInteractive || argv.prompt || '',
584+
feishuAutoStart: argv.feishu || false,
578585
fullContext: argv.allFiles || argv.all_files || false,
579586
coreTools: settings.coreTools || undefined,
580587
excludeTools,

packages/cli/src/ui/App.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,7 @@ const App = ({ config, settings, startupWarnings = [], version, promptExtensions
893893
}, []);
894894

895895
const initialPromptSubmitted = useRef(false);
896+
const feishuAutoStartTriggered = useRef(false);
896897

897898
const errorCount = useMemo(
898899
() =>
@@ -2538,6 +2539,40 @@ const App = ({ config, settings, startupWarnings = [], version, promptExtensions
25382539
sendPromptImmediately,
25392540
]);
25402541

2542+
// 🚀 --feishu 自启:启动就绪后自动执行 `/feishu start`,进入飞书常驻模式。
2543+
// 用于 self_update 自更新重启后无人值守地恢复飞书机器人。
2544+
// 用 handleSlashCommand(真正执行斜杠命令),不是 sendPromptImmediately(那会当成 AI prompt)。
2545+
useEffect(() => {
2546+
if (
2547+
config.getFeishuAutoStart?.() &&
2548+
!feishuAutoStartTriggered.current &&
2549+
!isAuthenticating &&
2550+
!isPreparingEnvironment &&
2551+
!isAuthDialogOpen &&
2552+
!isLoginDialogOpen &&
2553+
!isThemeDialogOpen &&
2554+
!isModelDialogOpen &&
2555+
!isEditorDialogOpen &&
2556+
!showPrivacyNotice &&
2557+
geminiClient?.isInitialized?.()
2558+
) {
2559+
feishuAutoStartTriggered.current = true;
2560+
void handleSlashCommand('/feishu start');
2561+
}
2562+
}, [
2563+
config,
2564+
isAuthenticating,
2565+
isPreparingEnvironment,
2566+
isAuthDialogOpen,
2567+
isLoginDialogOpen,
2568+
isThemeDialogOpen,
2569+
isModelDialogOpen,
2570+
isEditorDialogOpen,
2571+
showPrivacyNotice,
2572+
geminiClient,
2573+
handleSlashCommand,
2574+
]);
2575+
25412576
// Store quitting render content but don't return early to avoid hooks order issues
25422577
const quittingRender = quittingMessages ? (
25432578
<Box flexDirection="column" marginBottom={1}>

packages/cli/src/ui/commands/feishuCommand.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ import {
7272
SkillLoader,
7373
getSpecificMimeType,
7474
AudioReaderTool,
75+
SelfUpdateTool,
76+
launchRelaunchHelper,
7577
} from 'deepv-code-core';
7678
import { CommandService } from '../../services/CommandService.js';
7779
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
@@ -2174,6 +2176,23 @@ async function handleStart(context?: CommandContext): Promise<string> {
21742176
return lifecycleHint;
21752177
}
21762178

2179+
// 🆘 拦截 `/feishu restart`(或 /飞书 restart):热重启网关进程,用于 AI 卡死/
2180+
// 失去响应时的兜底恢复。由网关在 AI 处理之前直接处理(不经过 agent 循环,
2181+
// 因为 AI 可能已经卡住)。复用 self_update 的同一套跨平台外挂脚本,仅重启不更新。
2182+
const restartMatch = messageText.trim().match(/^\/(?:feishu|)\s+restart\b/i);
2183+
if (restartMatch) {
2184+
try {
2185+
launchRelaunchHelper({ type: 'none' });
2186+
// 给一点时间把回复发回飞书,再退出当前进程,让 detached 外挂接管重启。
2187+
setTimeout(() => {
2188+
process.exit(0);
2189+
}, 1500).unref?.();
2190+
return '🔄 收到重启指令,正在热重启飞书机器人(不更新版本),稍候我就回来。';
2191+
} catch (e: any) {
2192+
return `❌ 重启失败:${e?.message || String(e)}`;
2193+
}
2194+
}
2195+
21772196
// 拦截群内自助绑定的 `/bind` 命令
21782197
if (messageText.startsWith('/bind')) {
21792198
const parts = messageText.split(/\s+/);
@@ -2358,6 +2377,9 @@ async function handleStart(context?: CommandContext): Promise<string> {
23582377
// 注册飞书模式专属的音频朗读/转录工具(正常模式下不加载,避免污染和误导模型)
23592378
toolRegistry.registerTool(new AudioReaderTool(isolatedConfig));
23602379

2380+
// 注册飞书模式专属的自更新重启工具(普通 CLI 模式绝不注册)
2381+
toolRegistry.registerTool(new SelfUpdateTool(isolatedConfig));
2382+
23612383
await isolatedClient.setTools();
23622384
dlog(`[Router] Successfully registered session-specific tools for '${msg.chatId}'`);
23632385

@@ -3644,6 +3666,10 @@ async function handleStart(context?: CommandContext): Promise<string> {
36443666
// 🎯 动态注册专属的音频朗读/转录工具(正常模式下不加载,避免污染和误导模型)
36453667
toolRegistry.registerTool(new AudioReaderTool(config));
36463668

3669+
// 🎯 动态注册自更新重启工具(仅飞书模式可见):模型一调用即升级 easycode-ai
3670+
// 到 latest 并以 `easycode --feishu` 自动重启。普通 CLI 模式绝不注册。
3671+
toolRegistry.registerTool(new SelfUpdateTool(config));
3672+
36473673
await geminiClient.setTools();
36483674
dlog('Registered Feishu file-send tool and group-chat tool successfully.');
36493675
} catch (toolErr: any) {
@@ -3759,7 +3785,8 @@ async function handleStop(context?: CommandContext): Promise<string> {
37593785
const removed = toolRegistry.unregisterTool(SendFeishuFileTool.Name);
37603786
const removedGroupTool = toolRegistry.unregisterTool(CreateProjectGroupTool.Name);
37613787
const removedAudioTool = toolRegistry.unregisterTool(AudioReaderTool.Name);
3762-
if (removed || removedGroupTool || removedAudioTool) {
3788+
const removedSelfUpdate = toolRegistry.unregisterTool(SelfUpdateTool.Name);
3789+
if (removed || removedGroupTool || removedAudioTool || removedSelfUpdate) {
37633790
await geminiClient.setTools();
37643791
dlog('Unregistered Feishu file-send and group-chat tools successfully.');
37653792
}
@@ -4029,6 +4056,7 @@ async function handleLogout(context?: CommandContext): Promise<string> {
40294056
toolRegistry.unregisterTool(SendFeishuFileTool.Name);
40304057
toolRegistry.unregisterTool(CreateProjectGroupTool.Name);
40314058
toolRegistry.unregisterTool(AudioReaderTool.Name);
4059+
toolRegistry.unregisterTool(SelfUpdateTool.Name);
40324060
await geminiClient.setTools();
40334061
} catch {
40344062
// ignore

packages/core/src/config/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ export interface ConfigParameters {
189189
targetDir: string;
190190
debugMode: boolean;
191191
question?: string;
192+
feishuAutoStart?: boolean;
192193
fullContext?: boolean;
193194
coreTools?: string[];
194195
excludeTools?: string[];
@@ -253,6 +254,7 @@ export class Config {
253254
private readonly targetDir: string;
254255
private readonly debugMode: boolean;
255256
private readonly question: string | undefined;
257+
private readonly feishuAutoStart: boolean;
256258
private readonly fullContext: boolean;
257259
private readonly coreTools: string[] | undefined;
258260
private readonly excludeTools: string[] | undefined;
@@ -324,6 +326,7 @@ export class Config {
324326
this.targetDir = path.resolve(params.targetDir);
325327
this.debugMode = params.debugMode;
326328
this.question = params.question;
329+
this.feishuAutoStart = params.feishuAutoStart ?? false;
327330
this.fullContext = params.fullContext ?? false;
328331
this.coreTools = params.coreTools;
329332
this.excludeTools = params.excludeTools;
@@ -690,6 +693,10 @@ export class Config {
690693
return this.question;
691694
}
692695

696+
getFeishuAutoStart(): boolean {
697+
return this.feishuAutoStart;
698+
}
699+
693700
getFullContext(): boolean {
694701
return this.fullContext;
695702
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export * from './tools/shell.js';
128128
export * from './tools/web-search.js';
129129
export * from './tools/image-reader.js';
130130
export * from './tools/audio-reader.js';
131+
export * from './tools/self-update.js';
131132
export * from './tools/read-many-files.js';
132133
export * from './tools/read-lints.js';
133134
export * from './tools/lint-fix.js';
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Easy Code team
4+
* https://github.com/OrionStarAI/DeepVCode
5+
* SPDX-License-Identifier: Apache-2.0
6+
*/
7+
8+
import { describe, it, expect } from 'vitest';
9+
import {
10+
buildRelaunchScript,
11+
SelfUpdateTool,
12+
type RelaunchInstallMode,
13+
} from './self-update.js';
14+
import type { Config } from '../config/config.js';
15+
16+
/**
17+
* SelfUpdateTool / buildRelaunchScript — 飞书模式下"更新并重启 / 仅重启"。
18+
*
19+
* 进程不能自杀续命,故由一个 detached 的纯 JS 外挂脚本接力:
20+
* 父进程退出 → 外挂轮询父 PID 消失 → (按需)安装 → 拉起 easycode --feishu → 自删。
21+
*
22+
* 三种安装模式(install):
23+
* - { type: 'none' } 仅重启,不安装
24+
* - { type: 'npm' } npm i -g easycode-ai@latest
25+
* - { type: 'tgz', path } npm i -g <本地 tgz 绝对路径>
26+
*
27+
* 同一套外挂机制被 SelfUpdateTool 与 /feishu restart 共用,不造第二套轮子。
28+
*/
29+
describe('buildRelaunchScript', () => {
30+
const base = {
31+
parentPid: 12345,
32+
relaunchCommand: 'easycode',
33+
relaunchArgs: ['--feishu'],
34+
scriptPath: '/tmp/easycode-relaunch-12345.js',
35+
};
36+
37+
const npmMode: RelaunchInstallMode = { type: 'npm', packageName: 'easycode-ai' };
38+
const noneMode: RelaunchInstallMode = { type: 'none' };
39+
const tgzMode: RelaunchInstallMode = { type: 'tgz', path: '/abs/easycode-ai-1.1.3.tgz' };
40+
41+
it('always embeds parent PID polling via process.kill', () => {
42+
const script = buildRelaunchScript({ ...base, install: npmMode });
43+
expect(script).toContain('12345');
44+
expect(script).toContain('process.kill');
45+
});
46+
47+
it('npm mode embeds `<pkg>@latest` install', () => {
48+
const script = buildRelaunchScript({ ...base, install: npmMode });
49+
expect(script).toContain('easycode-ai@latest');
50+
expect(script).toContain('install');
51+
expect(script).toContain('-g');
52+
});
53+
54+
it('tgz mode embeds the local tgz absolute path install', () => {
55+
const script = buildRelaunchScript({ ...base, install: tgzMode });
56+
expect(script).toContain('/abs/easycode-ai-1.1.3.tgz');
57+
expect(script).toContain('install');
58+
expect(script).toContain('-g');
59+
// tgz 模式不应出现 @latest
60+
expect(script).not.toContain('@latest');
61+
});
62+
63+
it('none mode (restart only) installs nothing — INSTALL_ARGS is null', () => {
64+
const script = buildRelaunchScript({ ...base, install: noneMode });
65+
// 关键:安装参数被禁用(运行时跳过 npm install)
66+
expect(script).toContain('INSTALL_ARGS = null');
67+
expect(script).not.toContain('@latest');
68+
// 仍然要重启
69+
expect(script).toContain('easycode');
70+
expect(script).toContain('--feishu');
71+
});
72+
73+
it('always embeds the relaunch command and args', () => {
74+
for (const install of [npmMode, noneMode, tgzMode]) {
75+
const script = buildRelaunchScript({ ...base, install });
76+
expect(script).toContain('easycode');
77+
expect(script).toContain('--feishu');
78+
}
79+
});
80+
81+
it('always self-deletes the temp script', () => {
82+
for (const install of [npmMode, noneMode, tgzMode]) {
83+
const script = buildRelaunchScript({ ...base, install });
84+
expect(script).toContain('unlink');
85+
}
86+
});
87+
88+
it('always uses detached + unref to outlive itself', () => {
89+
for (const install of [npmMode, noneMode, tgzMode]) {
90+
const script = buildRelaunchScript({ ...base, install });
91+
expect(script).toContain('detached');
92+
expect(script).toContain('unref');
93+
}
94+
});
95+
96+
it('produces valid JavaScript for all modes', () => {
97+
for (const install of [npmMode, noneMode, tgzMode]) {
98+
const script = buildRelaunchScript({ ...base, install });
99+
expect(() => new Function(script)).not.toThrow();
100+
}
101+
});
102+
103+
it('embeds args via JSON to avoid injection', () => {
104+
const script = buildRelaunchScript({
105+
...base,
106+
install: noneMode,
107+
relaunchArgs: ['--feishu', '--weird "arg"'],
108+
});
109+
expect(script).toContain(JSON.stringify(['--feishu', '--weird "arg"']));
110+
});
111+
112+
it('does not branch on OS (single cross-platform path)', () => {
113+
const script = buildRelaunchScript({ ...base, install: npmMode });
114+
expect(script).not.toContain('cmd.exe');
115+
expect(script).not.toContain('/bin/bash');
116+
});
117+
118+
it('embeds tgz path via JSON to handle spaces/backslashes safely', () => {
119+
const winPath = 'C:\\Users\\me\\pkgs\\easycode-ai 1.1.3.tgz';
120+
const script = buildRelaunchScript({
121+
...base,
122+
install: { type: 'tgz', path: winPath },
123+
});
124+
expect(script).toContain(JSON.stringify(winPath));
125+
});
126+
});
127+
128+
describe('SelfUpdateTool', () => {
129+
const makeConfig = (): Config =>
130+
({ getModel: () => 'test-model' }) as unknown as Config;
131+
132+
it('has the correct tool name and no required params', () => {
133+
const tool = new SelfUpdateTool(makeConfig());
134+
expect(tool.name).toBe('self_update');
135+
expect(tool.schema.parameters?.required ?? []).toEqual([]);
136+
});
137+
138+
it('schema exposes action and source params to the model', () => {
139+
const tool = new SelfUpdateTool(makeConfig());
140+
const props = (tool.schema.parameters?.properties ?? {}) as Record<string, unknown>;
141+
expect(props).toHaveProperty('action');
142+
expect(props).toHaveProperty('source');
143+
});
144+
145+
it('validates action enum', () => {
146+
const tool = new SelfUpdateTool(makeConfig());
147+
expect(tool.validateToolParams({ action: 'update_and_restart' })).toBeNull();
148+
expect(tool.validateToolParams({ action: 'restart_only' })).toBeNull();
149+
expect(tool.validateToolParams({ action: 'bogus' as never })).not.toBeNull();
150+
});
151+
152+
it('requires source path when source is a local tgz', () => {
153+
const tool = new SelfUpdateTool(makeConfig());
154+
// source=local 但未给 path → 校验失败
155+
expect(
156+
tool.validateToolParams({ action: 'update_and_restart', source: 'local' }),
157+
).not.toBeNull();
158+
// 给了 path → 通过
159+
expect(
160+
tool.validateToolParams({
161+
action: 'update_and_restart',
162+
source: 'local',
163+
sourcePath: '/abs/pkg.tgz',
164+
}),
165+
).toBeNull();
166+
});
167+
168+
it('restart_only ignores source and is always valid', () => {
169+
const tool = new SelfUpdateTool(makeConfig());
170+
expect(tool.validateToolParams({ action: 'restart_only' })).toBeNull();
171+
});
172+
173+
it('description mentions update/restart', () => {
174+
const tool = new SelfUpdateTool(makeConfig());
175+
expect(tool.getDescription({}).toLowerCase()).toMatch(/updat|restart||/);
176+
});
177+
});

0 commit comments

Comments
 (0)