@foxden-app/foxclaw 0.11.0 → 0.12.0
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/.env.example +18 -1
- package/CHANGELOG.md +34 -0
- package/README.md +4 -3
- package/README_EN.md +4 -3
- package/dist/antigravity/controller.d.ts +7 -0
- package/dist/antigravity/controller.js +123 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +41 -5
- package/dist/core/engine_spi.d.ts +1 -1
- package/dist/core/orchestrator.d.ts +6 -0
- package/dist/core/orchestrator.js +78 -26
- package/dist/dsh/adapter.d.ts +48 -0
- package/dist/dsh/adapter.js +192 -0
- package/dist/dsh/client.d.ts +46 -0
- package/dist/dsh/client.js +184 -0
- package/dist/dsh/permissions_plugin.d.ts +30 -0
- package/dist/dsh/permissions_plugin.js +21 -0
- package/dist/dsh/runtime.d.ts +21 -0
- package/dist/dsh/runtime.js +45 -0
- package/dist/dsh/ui.d.ts +38 -0
- package/dist/dsh/ui.js +342 -0
- package/dist/i18n.d.ts +4 -0
- package/dist/i18n.js +12 -0
- package/dist/main.js +99 -39
- package/dist/store/database.d.ts +3 -1
- package/dist/store/database.js +8 -3
- package/dist/types.d.ts +1 -1
- package/docs/dsh.md +83 -0
- package/docs/zh/dsh.md +83 -0
- package/package.json +3 -2
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { Readable, Writable } from 'node:stream';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { ClientApp, ndJsonStream, PROTOCOL_VERSION } from '@agentclientprotocol/sdk';
|
|
9
|
+
/** An owned ACP process; protocol stdout stays separate from diagnostics. */
|
|
10
|
+
export class DshClient extends EventEmitter {
|
|
11
|
+
options;
|
|
12
|
+
scopeId;
|
|
13
|
+
logger;
|
|
14
|
+
process = null;
|
|
15
|
+
connection = null;
|
|
16
|
+
starting = null;
|
|
17
|
+
stopping = null;
|
|
18
|
+
optionsBySession = new Map();
|
|
19
|
+
capabilities = { image: false, resume: false, list: false };
|
|
20
|
+
policyPath = '';
|
|
21
|
+
policyWrite = Promise.resolve();
|
|
22
|
+
stderr = '';
|
|
23
|
+
permissionHandler;
|
|
24
|
+
cancelPermissions;
|
|
25
|
+
constructor(options, scopeId, logger) {
|
|
26
|
+
super();
|
|
27
|
+
this.options = options;
|
|
28
|
+
this.scopeId = scopeId;
|
|
29
|
+
this.logger = logger;
|
|
30
|
+
}
|
|
31
|
+
setAccess(preset) {
|
|
32
|
+
const write = this.policyWrite.then(() => this.writeAccess(preset));
|
|
33
|
+
this.policyWrite = write.catch(() => { });
|
|
34
|
+
return write;
|
|
35
|
+
}
|
|
36
|
+
async writeAccess(preset) {
|
|
37
|
+
const folder = path.join(this.options.runtimeDir, createHash('sha256').update(this.scopeId).digest('hex'));
|
|
38
|
+
await fs.mkdir(folder, { recursive: true, mode: 0o700 });
|
|
39
|
+
this.policyPath = path.join(folder, 'permissions');
|
|
40
|
+
const temporary = `${this.policyPath}.tmp`;
|
|
41
|
+
await fs.writeFile(temporary, preset === 'full-access' ? 'danger-full-access' : preset === 'read-only' ? 'read-only' : 'workspace-write', { mode: 0o600 });
|
|
42
|
+
await fs.rename(temporary, this.policyPath);
|
|
43
|
+
}
|
|
44
|
+
start() {
|
|
45
|
+
if (this.connection && !this.connection.signal.aborted)
|
|
46
|
+
return Promise.resolve();
|
|
47
|
+
this.starting ??= this.launch().finally(() => { this.starting = null; });
|
|
48
|
+
return this.starting;
|
|
49
|
+
}
|
|
50
|
+
async launch() {
|
|
51
|
+
if (this.stopping)
|
|
52
|
+
await this.stopping;
|
|
53
|
+
if (this.process)
|
|
54
|
+
await this.stop();
|
|
55
|
+
if (!this.policyPath)
|
|
56
|
+
await this.setAccess('default');
|
|
57
|
+
const patchPath = path.join(path.dirname(this.policyPath), 'bridge.patch.yml');
|
|
58
|
+
let pluginPath = fileURLToPath(new URL('./permissions_plugin.js', import.meta.url));
|
|
59
|
+
try {
|
|
60
|
+
await fs.access(pluginPath);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
pluginPath = pluginPath.replace(/\.js$/, '.ts');
|
|
64
|
+
}
|
|
65
|
+
await fs.writeFile(patchPath, `- insert:\n - id: foxclaw-permissions\n name: ${JSON.stringify(pathToFileURL(pluginPath).href)}\n config:\n policyPath: ${JSON.stringify(this.policyPath)}\n`, { mode: 0o600 });
|
|
66
|
+
const args = ['--profile', this.options.profile, ...this.options.patches.flatMap(p => ['--patch', path.resolve(p)]), '--patch', patchPath];
|
|
67
|
+
let command = this.options.cliBin;
|
|
68
|
+
let cwd = process.cwd();
|
|
69
|
+
if (this.options.sourceDir) {
|
|
70
|
+
cwd = path.resolve(this.options.sourceDir);
|
|
71
|
+
command = process.execPath;
|
|
72
|
+
args.unshift('--import', 'tsx/esm', path.join(cwd, 'apps/cli/src/bin.ts'));
|
|
73
|
+
}
|
|
74
|
+
else if (/\.[cm]?js$/.test(command)) {
|
|
75
|
+
args.unshift(path.resolve(command));
|
|
76
|
+
command = process.execPath;
|
|
77
|
+
}
|
|
78
|
+
const child = spawn(command, args, { cwd, env: { ...process.env, ...(this.options.home ? { DSH_HOME: this.options.home } : {}) }, stdio: 'pipe', windowsHide: true });
|
|
79
|
+
this.process = child;
|
|
80
|
+
this.stderr = '';
|
|
81
|
+
this.optionsBySession.clear();
|
|
82
|
+
const app = new ClientApp({ name: 'foxclaw' })
|
|
83
|
+
.onNotification('session/update', ({ params }) => {
|
|
84
|
+
if (params.update.sessionUpdate === 'config_option_update')
|
|
85
|
+
this.optionsBySession.set(params.sessionId, params.update.configOptions);
|
|
86
|
+
this.emit('update', params);
|
|
87
|
+
})
|
|
88
|
+
.onRequest('session/request_permission', ({ params, signal }) => this.permissionHandler?.(params, signal) ?? Promise.resolve({ outcome: { outcome: 'cancelled' } }));
|
|
89
|
+
const connection = app.connect(ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)));
|
|
90
|
+
this.connection = connection;
|
|
91
|
+
child.stdin.on('error', error => connection.close(error));
|
|
92
|
+
child.stderr.on('data', (data) => { this.stderr = (this.stderr + data.toString()).slice(-4000); });
|
|
93
|
+
child.once('error', error => connection.close(error));
|
|
94
|
+
child.once('exit', (code, signal) => {
|
|
95
|
+
connection.close(new Error(`DSH exited (${code ?? signal}): ${this.stderr}`));
|
|
96
|
+
if (this.process === child)
|
|
97
|
+
this.process = null;
|
|
98
|
+
this.emit('disconnected');
|
|
99
|
+
});
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
let timer;
|
|
102
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
103
|
+
timer = setTimeout(() => { controller.abort(); reject(new Error('DSH initialization timed out')); }, this.options.startupTimeoutMs);
|
|
104
|
+
});
|
|
105
|
+
try {
|
|
106
|
+
const initialized = await Promise.race([connection.agent.request('initialize', { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {}, clientInfo: { name: 'foxclaw', version: '1' } }, { cancellationSignal: controller.signal }), timeout]);
|
|
107
|
+
this.capabilities = { image: initialized.agentCapabilities?.promptCapabilities?.image === true, resume: initialized.agentCapabilities?.sessionCapabilities?.resume != null, list: initialized.agentCapabilities?.sessionCapabilities?.list != null };
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
await this.stop();
|
|
111
|
+
throw new Error(`Cannot start DSH: ${error instanceof Error ? error.message : String(error)}${this.stderr ? `\n${this.stderr}` : ''}`);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
get connected() { return this.connection !== null && !this.connection.signal.aborted; }
|
|
118
|
+
get imageSupported() { return this.capabilities.image; }
|
|
119
|
+
get agent() {
|
|
120
|
+
if (!this.connected)
|
|
121
|
+
throw new Error('DSH is disconnected');
|
|
122
|
+
return this.connection.agent;
|
|
123
|
+
}
|
|
124
|
+
async session(cwd, sessionId) {
|
|
125
|
+
await this.start();
|
|
126
|
+
if (sessionId && this.optionsBySession.has(sessionId))
|
|
127
|
+
return sessionId;
|
|
128
|
+
if (sessionId) {
|
|
129
|
+
if (!this.capabilities.resume)
|
|
130
|
+
throw new Error('This DSH profile cannot resume sessions');
|
|
131
|
+
const result = await this.agent.request('session/resume', { sessionId, cwd, mcpServers: [] });
|
|
132
|
+
this.optionsBySession.set(sessionId, result.configOptions ?? []);
|
|
133
|
+
return sessionId;
|
|
134
|
+
}
|
|
135
|
+
const result = await this.agent.request('session/new', { cwd, mcpServers: [] });
|
|
136
|
+
this.optionsBySession.set(result.sessionId, result.configOptions ?? []);
|
|
137
|
+
return result.sessionId;
|
|
138
|
+
}
|
|
139
|
+
configOptions(sessionId) { return this.optionsBySession.get(sessionId) ?? []; }
|
|
140
|
+
async setConfig(sessionId, configId, value) {
|
|
141
|
+
const result = await this.agent.request('session/set_config_option', { sessionId, configId, value });
|
|
142
|
+
this.optionsBySession.set(sessionId, result.configOptions);
|
|
143
|
+
}
|
|
144
|
+
async listSessions(cursor) {
|
|
145
|
+
await this.start();
|
|
146
|
+
if (!this.capabilities.list)
|
|
147
|
+
throw new Error('This DSH profile cannot list sessions');
|
|
148
|
+
return this.agent.request('session/list', cursor ? { cursor } : {});
|
|
149
|
+
}
|
|
150
|
+
async closeSession(sessionId) {
|
|
151
|
+
await this.agent.request('session/close', { sessionId });
|
|
152
|
+
this.optionsBySession.delete(sessionId);
|
|
153
|
+
}
|
|
154
|
+
stop() {
|
|
155
|
+
this.stopping ??= this.dispose().finally(() => { this.stopping = null; });
|
|
156
|
+
return this.stopping;
|
|
157
|
+
}
|
|
158
|
+
async dispose() {
|
|
159
|
+
this.cancelPermissions?.();
|
|
160
|
+
const child = this.process;
|
|
161
|
+
const connection = this.connection;
|
|
162
|
+
this.connection = null;
|
|
163
|
+
this.optionsBySession.clear();
|
|
164
|
+
if (!child || !child.pid) {
|
|
165
|
+
connection?.close();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const exited = new Promise(resolve => child.once('exit', () => resolve()));
|
|
169
|
+
child.stdin.end();
|
|
170
|
+
let timer;
|
|
171
|
+
await Promise.race([exited, new Promise(resolve => { timer = setTimeout(resolve, 6000); })]);
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
174
|
+
this.logger.warn('dsh.force_stop', { scopeId: this.scopeId });
|
|
175
|
+
child.kill('SIGTERM');
|
|
176
|
+
const killTimer = setTimeout(() => child.kill('SIGKILL'), 3000);
|
|
177
|
+
await exited;
|
|
178
|
+
clearTimeout(killTimer);
|
|
179
|
+
}
|
|
180
|
+
connection?.close();
|
|
181
|
+
if (this.process === child)
|
|
182
|
+
this.process = null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Minimal service face so the bridge plugin needs no DSH package dependency. */
|
|
2
|
+
interface Session {
|
|
3
|
+
header: {
|
|
4
|
+
parentSession?: string;
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
interface Context {
|
|
8
|
+
permissionPresets: {
|
|
9
|
+
set(session: Session, preset: string): void;
|
|
10
|
+
};
|
|
11
|
+
on(event: 'agent/created', listener: (payload: {
|
|
12
|
+
agent: {
|
|
13
|
+
session: Session;
|
|
14
|
+
};
|
|
15
|
+
}) => void): void;
|
|
16
|
+
on(event: 'agent/pre-step', listener: (payload: {
|
|
17
|
+
agent: {
|
|
18
|
+
session: Session;
|
|
19
|
+
};
|
|
20
|
+
}, next: () => Promise<unknown>) => Promise<unknown>, options: {
|
|
21
|
+
prepend: boolean;
|
|
22
|
+
}): void;
|
|
23
|
+
}
|
|
24
|
+
export declare const name = "foxclaw-permissions";
|
|
25
|
+
export declare const inject: string[];
|
|
26
|
+
/** Apply FoxClaw's selected native preset before fresh/restored root agents run. */
|
|
27
|
+
export declare function apply(ctx: Context, config: {
|
|
28
|
+
policyPath: string;
|
|
29
|
+
}): void;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
export const name = 'foxclaw-permissions';
|
|
3
|
+
export const inject = ['permissionPresets'];
|
|
4
|
+
/** Apply FoxClaw's selected native preset before fresh/restored root agents run. */
|
|
5
|
+
export function apply(ctx, config) {
|
|
6
|
+
const permissions = ctx.permissionPresets;
|
|
7
|
+
const select = (session) => {
|
|
8
|
+
if (session.header.parentSession)
|
|
9
|
+
return;
|
|
10
|
+
const preset = fs.readFileSync(config.policyPath, 'utf8').trim();
|
|
11
|
+
if (!['read-only', 'workspace-write', 'danger-full-access'].includes(preset)) {
|
|
12
|
+
throw new Error('Invalid FoxClaw DSH permission preset');
|
|
13
|
+
}
|
|
14
|
+
permissions.set(session, preset);
|
|
15
|
+
};
|
|
16
|
+
ctx.on('agent/created', ({ agent }) => select(agent.session));
|
|
17
|
+
ctx.on('agent/pre-step', async ({ agent }, next) => {
|
|
18
|
+
select(agent.session);
|
|
19
|
+
return next();
|
|
20
|
+
}, { prepend: true });
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AppConfig } from '../config.js';
|
|
2
|
+
import type { Logger } from '../logger.js';
|
|
3
|
+
import type { BridgeStore } from '../store/database.js';
|
|
4
|
+
/** Dedicated DSH bot using the same backend and channel orchestration as other bots. */
|
|
5
|
+
export declare class DshTelegramRuntime {
|
|
6
|
+
private readonly bot;
|
|
7
|
+
private readonly ui;
|
|
8
|
+
private readonly orchestrator;
|
|
9
|
+
private running;
|
|
10
|
+
constructor(config: AppConfig, store: BridgeStore, logger: Logger);
|
|
11
|
+
start(): Promise<void>;
|
|
12
|
+
stop(): Promise<void>;
|
|
13
|
+
getRuntimeStatus(): {
|
|
14
|
+
id: string;
|
|
15
|
+
username: string | null;
|
|
16
|
+
connected: boolean;
|
|
17
|
+
activeTurns: number;
|
|
18
|
+
pendingApprovals: number;
|
|
19
|
+
defaultBackend: "dsh";
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { TelegramGateway } from '../telegram/gateway.js';
|
|
2
|
+
import { TelegramMessagingPort } from '../channels/telegram/telegram_messaging_port.js';
|
|
3
|
+
import { getDshTelegramCommands } from '../i18n.js';
|
|
4
|
+
import { UnifiedChannelOrchestrator } from '../core/orchestrator.js';
|
|
5
|
+
import { DshUi } from './ui.js';
|
|
6
|
+
import { BRIDGE_SCOPE_TELEGRAM_PREFIX, parseTelegramTargetFromBridgeScope } from '../core/bridge_scope.js';
|
|
7
|
+
/** Dedicated DSH bot using the same backend and channel orchestration as other bots. */
|
|
8
|
+
export class DshTelegramRuntime {
|
|
9
|
+
bot;
|
|
10
|
+
ui;
|
|
11
|
+
orchestrator;
|
|
12
|
+
running = false;
|
|
13
|
+
constructor(config, store, logger) {
|
|
14
|
+
if (!config.dshBotToken)
|
|
15
|
+
throw new Error('DSH_BOT_TOKEN is required');
|
|
16
|
+
this.bot = new TelegramGateway(config.dshBotToken, config.tgAllowedUserId, config.tgAllowedChatId, config.telegramPollIntervalMs, store, logger, true, getDshTelegramCommands);
|
|
17
|
+
const messaging = new TelegramMessagingPort(this.bot);
|
|
18
|
+
this.ui = new DshUi(config, store, logger, messaging);
|
|
19
|
+
this.orchestrator = new UnifiedChannelOrchestrator({
|
|
20
|
+
config, store, logger, bot: this.bot, adapter: this.ui.adapter, messaging,
|
|
21
|
+
ownsScope: scopeId => scopeId.startsWith(BRIDGE_SCOPE_TELEGRAM_PREFIX) && parseTelegramTargetFromBridgeScope(scopeId).botId === this.bot.identity,
|
|
22
|
+
customUi: {
|
|
23
|
+
renderCustomStatus: async (scopeId, locale) => this.ui.status(scopeId, locale),
|
|
24
|
+
renderSetupMenu: async (scopeId, locale, messageId) => { await this.ui.setup(scopeId, locale, this.orchestrator, messageId); return true; },
|
|
25
|
+
renderModelsMenu: async (scopeId, locale, messageId) => { await this.ui.models(scopeId, locale, this.orchestrator, messageId); return true; },
|
|
26
|
+
handleCustomCommand: (scopeId, command, args, locale) => this.ui.command(scopeId, command, args, locale, this.orchestrator),
|
|
27
|
+
handleCustomCallback: (scopeId, data, locale, _messageId, event) => this.ui.callback(scopeId, data, locale, this.orchestrator, event),
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
this.orchestrator.registerInboundHandlers();
|
|
31
|
+
}
|
|
32
|
+
async start() {
|
|
33
|
+
await this.bot.initializeIdentity();
|
|
34
|
+
await this.orchestrator.start();
|
|
35
|
+
this.running = true;
|
|
36
|
+
}
|
|
37
|
+
async stop() {
|
|
38
|
+
this.running = false;
|
|
39
|
+
await this.orchestrator.stop();
|
|
40
|
+
await this.ui.stop();
|
|
41
|
+
}
|
|
42
|
+
getRuntimeStatus() {
|
|
43
|
+
return { id: this.bot.identity ?? 'dsh', username: this.bot.username, connected: this.running, activeTurns: this.orchestrator.getActiveTurnsCount(), pendingApprovals: this.ui.pendingApprovals, defaultBackend: 'dsh' };
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/dsh/ui.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { AppConfig } from '../config.js';
|
|
2
|
+
import type { BridgeStore } from '../store/database.js';
|
|
3
|
+
import type { Logger } from '../logger.js';
|
|
4
|
+
import type { TelegramMessagingPort } from '../channels/telegram/telegram_messaging_port.js';
|
|
5
|
+
import type { AppLocale } from '../types.js';
|
|
6
|
+
import type { TelegramCallbackEvent } from '../telegram/gateway.js';
|
|
7
|
+
import type { UnifiedChannelOrchestrator } from '../core/orchestrator.js';
|
|
8
|
+
import { DshEngineAdapter } from './adapter.js';
|
|
9
|
+
/** DSH controls shared by the unified bot and the optional dedicated bot. */
|
|
10
|
+
export declare class DshUi {
|
|
11
|
+
private readonly config;
|
|
12
|
+
private readonly store;
|
|
13
|
+
private readonly logger;
|
|
14
|
+
private readonly messaging;
|
|
15
|
+
private readonly actions;
|
|
16
|
+
private readonly approvals;
|
|
17
|
+
readonly adapter: DshEngineAdapter;
|
|
18
|
+
get pendingApprovals(): number;
|
|
19
|
+
constructor(config: AppConfig, store: BridgeStore, logger: Logger, messaging: TelegramMessagingPort);
|
|
20
|
+
private defaultAccess;
|
|
21
|
+
private copy;
|
|
22
|
+
status(scopeId: string, locale: AppLocale): string;
|
|
23
|
+
private button;
|
|
24
|
+
private panel;
|
|
25
|
+
setup(scopeId: string, locale: AppLocale, orchestrator: UnifiedChannelOrchestrator, messageId?: number): Promise<void>;
|
|
26
|
+
models(scopeId: string, locale: AppLocale, orchestrator: UnifiedChannelOrchestrator, messageId?: number): Promise<void>;
|
|
27
|
+
private efforts;
|
|
28
|
+
private selectModel;
|
|
29
|
+
private selectEffort;
|
|
30
|
+
private selectAccess;
|
|
31
|
+
private threads;
|
|
32
|
+
private open;
|
|
33
|
+
private plugins;
|
|
34
|
+
command(scopeId: string, command: string, args: string, locale: AppLocale, orchestrator: UnifiedChannelOrchestrator): Promise<boolean>;
|
|
35
|
+
callback(scopeId: string, data: string, locale: AppLocale, orchestrator: UnifiedChannelOrchestrator, event?: TelegramCallbackEvent): Promise<boolean>;
|
|
36
|
+
private requestPermission;
|
|
37
|
+
stop(): Promise<void>;
|
|
38
|
+
}
|