@walkhi/code-relax 0.1.0-beta.27 → 0.1.0-beta.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -67
- package/dist/bin/codex-remote.mjs +303 -239
- package/dist/bin/self-relay-server.mjs +2 -0
- package/dist/shared/app-server-events.cjs +102 -16
- package/dist/shared/code-browser.cjs +159 -0
- package/dist/shared/file-changes.cjs +31 -0
- package/dist/shared/file-citations.cjs +117 -82
- package/dist/shared/file-mentions.cjs +80 -35
- package/dist/shared/message-delivery.cjs +38 -0
- package/dist/shared/p2p-file-channel.cjs +74 -0
- package/dist/shared/server-requests.cjs +149 -41
- package/dist/shared/service-endpoints.cjs +11 -11
- package/dist/shared/timeline-sync.cjs +173 -156
- package/dist/skills/code-relax/SKILL.md +2 -1
- package/dist/src/bounded-process-log.mjs +63 -0
- package/dist/src/celsius-client.mjs +107 -0
- package/dist/src/code-browser.mjs +101 -0
- package/dist/src/desktop-launcher.mjs +30 -30
- package/dist/src/dots-approval-timeline.mjs +99 -0
- package/dist/src/dots-avatar.mjs +42 -0
- package/dist/src/dots-chat-projection.mjs +50 -0
- package/dist/src/dots-chat-server.mjs +412 -0
- package/dist/src/dots-message-images.mjs +319 -0
- package/dist/src/dots-profile.mjs +77 -0
- package/dist/src/dots-room-activity.mjs +94 -0
- package/dist/src/dots-room-client.mjs +135 -0
- package/dist/src/dots-room-projection.mjs +94 -0
- package/dist/src/dots-schedules.mjs +66 -0
- package/dist/src/dots-summary-tasks.mjs +32 -0
- package/dist/src/dots-task-catalog.mjs +411 -0
- package/dist/src/file-transfer-range.mjs +22 -0
- package/dist/src/file-upload-session.mjs +130 -124
- package/dist/src/hosted-history-app-server.mjs +103 -0
- package/dist/src/hosted-history-cache.mjs +113 -0
- package/dist/src/lan-socket-server.mjs +205 -160
- package/dist/src/managed-app-server.mjs +241 -238
- package/dist/src/message-upload-files.mjs +74 -0
- package/dist/src/platform/windows/activate-desktop.ps1 +48 -0
- package/dist/src/platform/windows/codex-runtime.mjs +253 -251
- package/dist/src/platform/windows/desktop-host.mjs +38 -32
- package/dist/src/platform/windows/desktop-info.ps1 +2 -2
- package/dist/src/platform/windows/desktop-project-registration.mjs +153 -0
- package/dist/src/platform/windows/dots-profile.mjs +49 -0
- package/dist/src/platform/windows/launch-worker.mjs +41 -42
- package/dist/src/platform/windows/project-locations.mjs +16 -0
- package/dist/src/project-create.mjs +119 -0
- package/dist/src/project-management.mjs +69 -0
- package/dist/src/self-relay/account-connections.mjs +52 -0
- package/dist/src/self-relay/admin-page.mjs +80 -80
- package/dist/src/self-relay/admin-style.mjs +14 -9
- package/dist/src/self-relay/connector.mjs +23 -5
- package/dist/src/self-relay/email-accounts.mjs +35 -10
- package/dist/src/self-relay/file-wire.mjs +13 -0
- package/dist/src/self-relay/lifecycle.mjs +22 -4
- package/dist/src/self-relay/long-pairing.mjs +40 -0
- package/dist/src/self-relay/p2p-probe.mjs +152 -123
- package/dist/src/self-relay/rtc-file-bridge.mjs +41 -0
- package/dist/src/self-relay/server.mjs +922 -849
- package/dist/src/server.mjs +474 -199
- package/dist/src/service-control.mjs +13 -3
- package/dist/src/service-doctor.mjs +72 -61
- package/dist/src/service-lifecycle.mjs +51 -43
- package/dist/src/shared-app-server.mjs +166 -107
- package/dist/src/shared-catalog.mjs +132 -40
- package/dist/src/shared-thread-stream.mjs +103 -25
- package/dist/src/usage-reset.mjs +17 -0
- package/dist/src/workstation-downloads.mjs +120 -0
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { startWindowsProcess } from './background-process.mjs';
|
|
1
|
+
import { startWindowsProcess } from './background-process.mjs';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
@@ -8,45 +8,51 @@ const exec = promisify(execFile);
|
|
|
8
8
|
const directory = path.dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
export const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
|
|
10
10
|
export async function runScript(name, args = []) {
|
|
11
|
-
const { stdout } = await exec(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', path.join(directory, name), ...args], { windowsHide: true, encoding: 'utf8', timeout: 30000 });
|
|
11
|
+
const { stdout } = await exec(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', path.join(directory, name), ...args], { windowsHide: true, encoding: 'utf8', timeout: 30000 });
|
|
12
12
|
return stdout.trim();
|
|
13
13
|
}
|
|
14
|
-
export const desktopHost = {
|
|
14
|
+
export const desktopHost = {
|
|
15
15
|
inspect: async () => JSON.parse(await runScript('desktop-info.ps1')),
|
|
16
16
|
process: async pid => JSON.parse(await runScript('desktop-info.ps1', ['-ProcessId', String(pid)])),
|
|
17
17
|
sharedServiceUrl,
|
|
18
|
-
start: startWindowsProcess,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
async
|
|
28
|
-
|
|
29
|
-
await this.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
18
|
+
start: startWindowsProcess,
|
|
19
|
+
startDesktop: (executable, environment) => runScript('activate-desktop.ps1', [
|
|
20
|
+
'-Executable', executable, '-AppServerUrl', environment.CODEX_APP_SERVER_WS_URL,
|
|
21
|
+
]),
|
|
22
|
+
async desktopReady() {
|
|
23
|
+
return (await this.inspect()).hasWindow === true && Boolean(await this.sharedServiceUrl());
|
|
24
|
+
},
|
|
25
|
+
closeDesktop: executable => runScript('stop-desktop.ps1', ['-Executable', executable]),
|
|
26
|
+
stop: pid => process.kill(pid),
|
|
27
|
+
async prepareKernelRestart() {
|
|
28
|
+
const info = await this.inspect();
|
|
29
|
+
if (!info.running || !await this.sharedServiceUrl()) return null;
|
|
30
|
+
await this.closeDesktop(info.desktop);
|
|
31
|
+
return info.desktop;
|
|
32
|
+
},
|
|
33
|
+
async resumeAfterKernelRestart(executable, environment) {
|
|
34
|
+
if (!executable) return;
|
|
35
|
+
await this.startDesktop(executable, environment);
|
|
36
|
+
const deadline = Date.now() + 30000;
|
|
37
|
+
while (!await this.desktopReady()) {
|
|
38
|
+
if (Date.now() >= deadline) throw new Error('Codex 内核已就绪,但 Desktop 未完成重新接入。');
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
40
|
+
}
|
|
41
|
+
},
|
|
36
42
|
ready: async url => { try { return (await fetch(url, { signal: AbortSignal.timeout(1000) })).ok; } catch { return false; } },
|
|
37
|
-
};
|
|
38
|
-
export const resolveStateRoot = stateRoot => runScript('resolve-state-root.ps1', ['-StateRoot', stateRoot]);
|
|
43
|
+
};
|
|
44
|
+
export const resolveStateRoot = stateRoot => runScript('resolve-state-root.ps1', ['-StateRoot', stateRoot]);
|
|
39
45
|
|
|
40
|
-
export async function removeDesktopShortcut({ serviceRoot, nodeExecutable }, destination) {
|
|
46
|
+
export async function removeDesktopShortcut({ serviceRoot, nodeExecutable }, destination) {
|
|
41
47
|
const entry = path.join(serviceRoot, 'dist/bin/codex-remote.mjs');
|
|
42
48
|
const args = ['-NodePath', nodeExecutable, '-Entry', entry];
|
|
43
49
|
if (destination) args.push('-Destination', destination);
|
|
44
50
|
return JSON.parse(await runScript('desktop-shortcuts.ps1', args));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export async function residentStartup({ serviceRoot, nodeExecutable }, action = 'status') {
|
|
48
|
-
const { stdout } = await exec(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File',
|
|
49
|
-
path.join(serviceRoot, 'dist/src/platform/windows/resident-startup.ps1'), '-NodePath', nodeExecutable,
|
|
50
|
-
'-Entry', path.join(serviceRoot, 'dist/bin/codex-remote.mjs'), '-Action', action], { windowsHide: true, encoding: 'utf8' });
|
|
51
|
-
return JSON.parse(stdout.trim());
|
|
52
|
-
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function residentStartup({ serviceRoot, nodeExecutable }, action = 'status') {
|
|
54
|
+
const { stdout } = await exec(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File',
|
|
55
|
+
path.join(serviceRoot, 'dist/src/platform/windows/resident-startup.ps1'), '-NodePath', nodeExecutable,
|
|
56
|
+
'-Entry', path.join(serviceRoot, 'dist/bin/codex-remote.mjs'), '-Action', action], { windowsHide: true, encoding: 'utf8' });
|
|
57
|
+
return JSON.parse(stdout.trim());
|
|
58
|
+
}
|
|
@@ -8,7 +8,7 @@ if ($ProcessId -gt 0) {
|
|
|
8
8
|
exit
|
|
9
9
|
}
|
|
10
10
|
$package = Get-AppxPackage OpenAI.Codex | Select-Object -First 1
|
|
11
|
-
if (-not $package) { @{ desktop=$null; running=$false } | ConvertTo-Json -Compress; exit }
|
|
11
|
+
if (-not $package) { @{ desktop=$null; running=$false } | ConvertTo-Json -Compress; exit }
|
|
12
12
|
$desktop = Join-Path $package.InstallLocation 'app/ChatGPT.exe'
|
|
13
13
|
$running = @(Get-Process ChatGPT -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $desktop })
|
|
14
|
-
@{ desktop=$desktop; running=($running.Count -gt 0) } | ConvertTo-Json -Compress
|
|
14
|
+
@{ desktop=$desktop; running=($running.Count -gt 0); hasWindow=(@($running | Where-Object { $_.MainWindowHandle -ne 0 }).Count -gt 0) } | ConvertTo-Json -Compress
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { desktopHost } from './desktop-host.mjs';
|
|
6
|
+
import { processAlive } from '../../background-process.mjs';
|
|
7
|
+
|
|
8
|
+
// This is a version-specific Desktop state adapter, not a second project store.
|
|
9
|
+
// Desktop reads this file once and flushes its complete in-memory snapshot.
|
|
10
|
+
const desktopVersion = '26.1002.7124.0';
|
|
11
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
12
|
+
const key = value => path.win32.normalize(value).replace(/[\\/]+$/, '').toLowerCase();
|
|
13
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
14
|
+
const fail = (message, details) => { throw Object.assign(new Error(message), { statusCode: 409, details }); };
|
|
15
|
+
|
|
16
|
+
export const desktopProjectRestartWarning = '创建项目需要短暂关闭并重新打开 Codex Desktop,以完成桌面登记。请先保存桌面上未发送的内容;仅由旧桌面实例承接的任务可能中断。手机连接、工作站服务和 Codex 内核保持运行。';
|
|
17
|
+
|
|
18
|
+
function parseState(bytes) {
|
|
19
|
+
const state = JSON.parse(bytes.toString('utf8').replace(/^\uFEFF/, ''));
|
|
20
|
+
if (!object(state) || !object(state['local-projects']) || !Array.isArray(state['project-order'])
|
|
21
|
+
|| state['project-order'].some(id => typeof id !== 'string')) {
|
|
22
|
+
fail('Codex Desktop 项目文件结构与已核对版本不符,未修改桌面文件。');
|
|
23
|
+
}
|
|
24
|
+
return state;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function registerProjectInState(state, project, codexDataRoot) {
|
|
28
|
+
if (!Number.isFinite(project.createdAt) || !Number.isFinite(project.updatedAt)) {
|
|
29
|
+
fail('内核项目时间字段无效,未修改桌面文件。');
|
|
30
|
+
}
|
|
31
|
+
const projects = state['local-projects'];
|
|
32
|
+
const scope = state['app-server-project-id-by-legacy-project-id-by-host']?.[`local:${codexDataRoot}`];
|
|
33
|
+
if (scope !== undefined && !object(scope)) fail('Codex Desktop 项目映射结构无效,未修改桌面文件。');
|
|
34
|
+
for (const [legacyId, nativeId] of Object.entries(scope || {})) {
|
|
35
|
+
if ((legacyId === project.id || nativeId === project.id) && legacyId !== nativeId) {
|
|
36
|
+
fail('该项目已有不同的桌面身份映射,未覆盖已有登记。');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const previous = projects[project.id];
|
|
40
|
+
const rootPaths = project.roots.map(root => root.path);
|
|
41
|
+
if (previous && (previous.id !== project.id || previous.name !== project.name || !Array.isArray(previous.rootPaths)
|
|
42
|
+
|| previous.rootPaths.length !== rootPaths.length || previous.rootPaths.some((root, index) => key(root) !== key(rootPaths[index])))) {
|
|
43
|
+
fail('该项目的桌面登记内容已变化,未覆盖已有登记。');
|
|
44
|
+
}
|
|
45
|
+
// With identical native/desktop IDs, Desktop storeProject adopts the native
|
|
46
|
+
// project at startup. No legacy mapping, thread reassignment or import is needed.
|
|
47
|
+
const result = { ...state, 'local-projects': { ...projects, [project.id]: previous || {
|
|
48
|
+
id: project.id, name: project.name, rootPaths,
|
|
49
|
+
createdAt: project.createdAt * 1000, updatedAt: project.updatedAt * 1000,
|
|
50
|
+
} } };
|
|
51
|
+
result['project-order'] = state['project-order'].includes(project.id)
|
|
52
|
+
? state['project-order'] : [project.id, ...state['project-order']];
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function acquireLock(stateRoot, operationId) {
|
|
57
|
+
const file = path.join(stateRoot, 'desktop-launch.lock');
|
|
58
|
+
try {
|
|
59
|
+
const previous = JSON.parse(await fs.readFile(file, 'utf8') || 'null');
|
|
60
|
+
if (previous?.owner === 'project-registration' && Number.isInteger(previous.pid) && !processAlive(previous.pid)) {
|
|
61
|
+
await fs.unlink(file);
|
|
62
|
+
}
|
|
63
|
+
} catch (error) { if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error; }
|
|
64
|
+
let handle;
|
|
65
|
+
try { handle = await fs.open(file, 'wx'); }
|
|
66
|
+
catch (error) { if (error.code === 'EEXIST') fail('Codex Desktop 正在启动或登记项目,请稍后再试。'); throw error; }
|
|
67
|
+
try { await handle.writeFile(JSON.stringify({ owner: 'project-registration', pid: process.pid, operationId })); }
|
|
68
|
+
catch (error) { await handle.close(); await fs.unlink(file); throw error; }
|
|
69
|
+
return async () => { await handle.close(); await fs.unlink(file); };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function withDesktopProjectRegistration(options, action) {
|
|
73
|
+
const { stateRoot, codexDataRoot, operationId, confirmed, serverUrl,
|
|
74
|
+
host = desktopHost, wait = delay, readyTimeoutMs = 30000 } = options;
|
|
75
|
+
const stateFile = path.join(codexDataRoot, '.codex-global-state.json');
|
|
76
|
+
const release = await acquireLock(stateRoot, operationId);
|
|
77
|
+
try {
|
|
78
|
+
try { await fs.access(path.join(stateRoot, 'shared-recovery.lock')); fail('Codex Desktop 正在恢复连接,请稍后创建项目。'); }
|
|
79
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
80
|
+
const info = await host.inspect();
|
|
81
|
+
if (!info.desktop?.includes(`OpenAI.Codex_${desktopVersion}_`)) {
|
|
82
|
+
fail(`当前 Codex Desktop 版本尚未核对项目登记结构;已支持版本为 ${desktopVersion}。`);
|
|
83
|
+
}
|
|
84
|
+
parseState(await fs.readFile(stateFile));
|
|
85
|
+
if (info.running && confirmed !== true) fail(desktopProjectRestartWarning, { desktopRestartRequired: true });
|
|
86
|
+
if (info.running && await host.sharedServiceUrl() !== serverUrl) {
|
|
87
|
+
fail('Codex Desktop 尚未接入当前工作站内核,请先完成 Desktop 接入后创建项目。');
|
|
88
|
+
}
|
|
89
|
+
return await action({
|
|
90
|
+
async register(project, operation, saveOperation) {
|
|
91
|
+
let restartNeeded = operation.desktopWasRunning === true;
|
|
92
|
+
let registrationError;
|
|
93
|
+
const current = await host.inspect();
|
|
94
|
+
if (current.desktop !== info.desktop) fail('Codex Desktop 安装版本发生变化,项目保留,未修改桌面文件。');
|
|
95
|
+
if (current.running) {
|
|
96
|
+
if (confirmed !== true) fail(desktopProjectRestartWarning, { desktopRestartRequired: true });
|
|
97
|
+
if (await host.sharedServiceUrl() !== serverUrl) fail('Codex Desktop 内核连接已变化,未关闭桌面。');
|
|
98
|
+
restartNeeded = true;
|
|
99
|
+
operation.desktopWasRunning = true;
|
|
100
|
+
await saveOperation();
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
if (current.running) await host.closeDesktop(info.desktop);
|
|
104
|
+
if ((await host.inspect()).running) fail('Codex Desktop 尚未退出,未修改桌面文件。');
|
|
105
|
+
const original = await fs.readFile(stateFile);
|
|
106
|
+
const state = parseState(original);
|
|
107
|
+
const updated = registerProjectInState(state, project, codexDataRoot);
|
|
108
|
+
if (JSON.stringify(updated) !== JSON.stringify(state)) {
|
|
109
|
+
const backupDirectory = path.join(stateRoot, 'desktop-project-backups');
|
|
110
|
+
await fs.mkdir(backupDirectory, { recursive: true });
|
|
111
|
+
const backup = path.join(backupDirectory, `${operationId}-${randomUUID()}.json`);
|
|
112
|
+
await fs.writeFile(backup, original, { flag: 'wx', mode: 0o600 });
|
|
113
|
+
operation.desktopBackup = backup;
|
|
114
|
+
await saveOperation();
|
|
115
|
+
const temporary = `${stateFile}.${operationId}.tmp`;
|
|
116
|
+
try {
|
|
117
|
+
const source = original.toString('utf8');
|
|
118
|
+
const replacement = Buffer.from(`${source.startsWith('\uFEFF') ? '\uFEFF' : ''}${JSON.stringify(updated)}${source.endsWith('\n') ? '\n' : ''}`);
|
|
119
|
+
await fs.writeFile(temporary, replacement, { flag: 'wx', mode: 0o600 });
|
|
120
|
+
if ((await host.inspect()).running || digest(await fs.readFile(stateFile)) !== digest(original)) {
|
|
121
|
+
fail('Codex Desktop 状态在登记期间发生变化,未覆盖原文件,请核对后重试。');
|
|
122
|
+
}
|
|
123
|
+
await fs.rename(temporary, stateFile);
|
|
124
|
+
if (digest(await fs.readFile(stateFile)) !== digest(replacement)) throw new Error('Codex Desktop 登记文件未能核对,原状态备份已保留。');
|
|
125
|
+
} finally { await fs.rm(temporary, { force: true }); }
|
|
126
|
+
}
|
|
127
|
+
operation.desktopRegistered = true;
|
|
128
|
+
await saveOperation();
|
|
129
|
+
} catch (error) { registrationError = error; }
|
|
130
|
+
// Restore the UI even if the registry write failed. This never starts or
|
|
131
|
+
// restarts a kernel and never changes the service's active connections.
|
|
132
|
+
if (restartNeeded) {
|
|
133
|
+
try {
|
|
134
|
+
if (!(await host.inspect()).running) await host.startDesktop(info.desktop, { CODEX_APP_SERVER_WS_URL: serverUrl });
|
|
135
|
+
const deadline = Date.now() + readyTimeoutMs;
|
|
136
|
+
while (!await host.desktopReady()) {
|
|
137
|
+
if (Date.now() >= deadline) throw new Error('Codex Desktop 尚未重新就绪。');
|
|
138
|
+
await wait(500);
|
|
139
|
+
}
|
|
140
|
+
if (await host.sharedServiceUrl() !== serverUrl) throw new Error('Desktop 未接入原工作站内核。');
|
|
141
|
+
} catch (error) {
|
|
142
|
+
throw new Error([registrationError?.message, `项目已保留,重新打开 Codex Desktop 未完成:${error.message}`].filter(Boolean).join(' '));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (registrationError) throw registrationError;
|
|
146
|
+
const finalState = parseState(await fs.readFile(stateFile));
|
|
147
|
+
const verified = registerProjectInState(finalState, project, codexDataRoot);
|
|
148
|
+
if (JSON.stringify(verified) !== JSON.stringify(finalState)) throw new Error('项目已创建,但 Desktop 登记未能在最终文件中核对。');
|
|
149
|
+
return { status: 'registered', restarted: restartNeeded };
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
} finally { await release(); }
|
|
153
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { dotsProfile } from '../../dots-profile.mjs';
|
|
5
|
+
|
|
6
|
+
// Desktop 26.928.2636.0 persists its current TBO sidebar profiles here.
|
|
7
|
+
// Read display names, appearance colors and snapshot references; identity comes from the authenticated kernel.
|
|
8
|
+
export function desktopDotsProfiles(codexDataRoot, stateRoot) {
|
|
9
|
+
const snapshotPath = ({ accountId, userId }) => stateRoot && path.join(stateRoot, 'dots-profiles',
|
|
10
|
+
createHash('sha256').update(JSON.stringify([accountId, userId])).digest('hex') + '.json');
|
|
11
|
+
const read = async ({ accountId, userId }) => {
|
|
12
|
+
let state;
|
|
13
|
+
try {
|
|
14
|
+
state = JSON.parse(await fs.readFile(path.join(codexDataRoot, '.codex-global-state.json'), 'utf8'));
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error.code !== 'ENOENT') throw error;
|
|
17
|
+
}
|
|
18
|
+
const cache = state?.['electron-persisted-atom-state']?.['cloud-aeon-sidebar-cache-v1'];
|
|
19
|
+
const names = new Map();
|
|
20
|
+
if (!accountId || !userId) return names;
|
|
21
|
+
const matching = cache?.hostId === 'durable' && cache.accountId === accountId && cache.userId === userId;
|
|
22
|
+
for (const [threadId, profile] of Object.entries(matching ? cache.profilesByThreadId || {} : {})) {
|
|
23
|
+
const value = dotsProfile(profile);
|
|
24
|
+
for (const id of [threadId, profile.active_root_thread_id, profile.root_thread_id]) {
|
|
25
|
+
if (typeof id === 'string' && id) names.set(id, value);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const file = snapshotPath({ accountId, userId });
|
|
29
|
+
if (file) {
|
|
30
|
+
try {
|
|
31
|
+
const saved = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
32
|
+
if (saved.accountId !== accountId || saved.userId !== userId || !Array.isArray(saved.profiles)) throw new Error('点点资料缓存身份无效。');
|
|
33
|
+
const profiles = new Map(saved.profiles);
|
|
34
|
+
const byId = new Map([...profiles.values()].map(profile => [profile.profileId, profile]));
|
|
35
|
+
for (const [id, profile] of names) if (byId.has(profile.profileId)) names.set(id, byId.get(profile.profileId));
|
|
36
|
+
for (const [id, profile] of profiles) names.set(id, profile);
|
|
37
|
+
} catch (error) { if (error.code !== 'ENOENT') console.error('读取点点资料缓存失败:', error.message); }
|
|
38
|
+
}
|
|
39
|
+
return names;
|
|
40
|
+
};
|
|
41
|
+
read.save = async (identity, profiles) => {
|
|
42
|
+
const file = snapshotPath(identity);
|
|
43
|
+
if (!file) return;
|
|
44
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
45
|
+
await fs.writeFile(file + '.tmp', JSON.stringify({ ...identity, profiles: [...profiles] }), 'utf8');
|
|
46
|
+
await fs.rename(file + '.tmp', file);
|
|
47
|
+
};
|
|
48
|
+
return read;
|
|
49
|
+
}
|
|
@@ -1,42 +1,41 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
result.error
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
fs.
|
|
42
|
-
fs.renameSync(temporary, readyPath);
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { attachProcessLog } from '../../bounded-process-log.mjs';
|
|
4
|
+
|
|
5
|
+
const [requestPath, readyPath, instance, diagnosticPath] = process.argv.slice(2);
|
|
6
|
+
let result = { instance, brokerPid: process.pid };
|
|
7
|
+
|
|
8
|
+
function diagnostic(message) {
|
|
9
|
+
if (!diagnosticPath) return;
|
|
10
|
+
fs.appendFileSync(diagnosticPath, `${new Date().toISOString()} ${message}\n`, 'utf8');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const spec = JSON.parse(fs.readFileSync(requestPath, 'utf8').replace(/^\uFEFF/u, ''));
|
|
15
|
+
diagnostic('request loaded');
|
|
16
|
+
if (spec.instance !== instance) throw new Error('Windows process broker request identity mismatch.');
|
|
17
|
+
fs.rmSync(requestPath, { force: true });
|
|
18
|
+
const logs = spec.logs || [];
|
|
19
|
+
const child = spawn(spec.executable, spec.args || [], {
|
|
20
|
+
windowsHide: true,
|
|
21
|
+
env: spec.environment,
|
|
22
|
+
stdio: ['ignore', logs[0] ? 'pipe' : 'ignore', logs[1] ? 'pipe' : 'ignore'],
|
|
23
|
+
});
|
|
24
|
+
if (child.stdout) attachProcessLog(child.stdout, logs[0]);
|
|
25
|
+
if (child.stderr) attachProcessLog(child.stderr, logs[1]);
|
|
26
|
+
await new Promise((resolve, reject) => {
|
|
27
|
+
child.once('spawn', resolve);
|
|
28
|
+
child.once('error', reject);
|
|
29
|
+
});
|
|
30
|
+
// Keep this WMI-owned broker alive with the target so Codex tools inherit
|
|
31
|
+
// its hidden console instead of creating a visible console per command.
|
|
32
|
+
result.pid = child.pid;
|
|
33
|
+
diagnostic(`target started (PID ${child.pid})`);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
result.error = error?.stack || error?.message || String(error);
|
|
36
|
+
try { diagnostic(`failed: ${result.error}`); } catch {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const temporary = `${readyPath}.tmp`;
|
|
40
|
+
fs.writeFileSync(temporary, JSON.stringify(result), 'utf8');
|
|
41
|
+
fs.renameSync(temporary, readyPath);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { powershell } from './desktop-host.mjs';
|
|
5
|
+
|
|
6
|
+
const exec = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
export async function projectLocations() {
|
|
9
|
+
const command = "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $projectDocuments = [Environment]::GetFolderPath('MyDocuments'); $projectUserDirectory = [Environment]::GetFolderPath('UserProfile'); $projectDownloads = [Environment]::ExpandEnvironmentVariables((Get-ItemPropertyValue -LiteralPath 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders' -Name '{374DE290-123F-4565-9164-39C4925E467B}' -ErrorAction Stop)); $projectDriveRoots = @(Get-PSDrive -PSProvider FileSystem | ForEach-Object { $_.Root }); @{ documents = $projectDocuments; home = $projectUserDirectory; downloads = $projectDownloads; roots = $projectDriveRoots } | ConvertTo-Json -Compress";
|
|
10
|
+
const { stdout } = await exec(powershell, ['-NoProfile', '-NonInteractive', '-Command', command],
|
|
11
|
+
{ windowsHide: true, encoding: 'utf8', timeout: 10000 });
|
|
12
|
+
const locations = JSON.parse(stdout);
|
|
13
|
+
if (!path.isAbsolute(locations.documents)) throw new Error('无法取得电脑的文档目录。');
|
|
14
|
+
if (!path.isAbsolute(locations.downloads)) throw new Error('无法取得电脑的下载目录。');
|
|
15
|
+
return { defaultParent: path.join(locations.documents, 'ChatGPT'), home: locations.home, downloads: locations.downloads, roots: locations.roots };
|
|
16
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readRecord, writeRecord } from './service-lifecycle.mjs';
|
|
5
|
+
import { projectRoots, projectSummary } from './project-management.mjs';
|
|
6
|
+
import { projectLocations } from './platform/windows/project-locations.mjs';
|
|
7
|
+
import { withDesktopProjectRegistration } from './platform/windows/desktop-project-registration.mjs';
|
|
8
|
+
|
|
9
|
+
const pending = new Map();
|
|
10
|
+
const uuid = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
|
11
|
+
const fail = message => { throw Object.assign(new Error(message), { statusCode: 400 }); };
|
|
12
|
+
const pathKey = value => path.normalize(value).toLowerCase();
|
|
13
|
+
|
|
14
|
+
function defaultName(name) {
|
|
15
|
+
let value = path.basename(name).trim().replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/[ .]+$/g, '') || 'New project';
|
|
16
|
+
if (/^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(value)) value = `_${value}`;
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function createWorkstationProject(server, stateRoot, codexDataRoot, body, dependencies = {}) {
|
|
21
|
+
if (!uuid.test(body.idempotencyKey || '')) fail('项目创建标识无效。');
|
|
22
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
23
|
+
if (!name) fail('请输入项目名称。');
|
|
24
|
+
if (!Array.isArray(body.roots) || body.roots.some(root => typeof root?.path !== 'string' || !path.isAbsolute(root.path.trim()))) {
|
|
25
|
+
fail('项目文件夹路径无效。');
|
|
26
|
+
}
|
|
27
|
+
const roots = body.roots.map(root => ({ path: path.normalize(root.path.trim()) }));
|
|
28
|
+
const signature = createHash('sha256').update(JSON.stringify({ name, roots })).digest('hex');
|
|
29
|
+
const directory = path.join(stateRoot, 'project-create-operations');
|
|
30
|
+
const record = path.join(directory, `${body.idempotencyKey}.json`);
|
|
31
|
+
const running = pending.get(record);
|
|
32
|
+
if (running) {
|
|
33
|
+
if (running.signature !== signature) fail('同一个创建请求不能修改项目名称或目录。');
|
|
34
|
+
return running.promise;
|
|
35
|
+
}
|
|
36
|
+
const promise = perform().finally(() => pending.delete(record));
|
|
37
|
+
pending.set(record, { signature, promise });
|
|
38
|
+
return promise;
|
|
39
|
+
|
|
40
|
+
async function perform() {
|
|
41
|
+
await fs.mkdir(directory, { recursive: true });
|
|
42
|
+
let operation = readRecord(record);
|
|
43
|
+
if (operation && operation.signature !== signature) fail('项目创建标识已用于其他内容,请重新打开项目编辑器。');
|
|
44
|
+
if (operation?.result) return operation.result;
|
|
45
|
+
operation ||= { signature, name, roots };
|
|
46
|
+
const save = () => writeRecord(record, operation);
|
|
47
|
+
const rpc = (method, params) => server.request(method, params);
|
|
48
|
+
const register = dependencies.registration || withDesktopProjectRegistration;
|
|
49
|
+
try {
|
|
50
|
+
return await register({ stateRoot, codexDataRoot, operationId: body.idempotencyKey,
|
|
51
|
+
confirmed: body.confirmDesktopRestart === true, serverUrl: server.url }, async desktop => {
|
|
52
|
+
// The restart confirmation and version/state checks happen before any
|
|
53
|
+
// directory or project write. Existing folders are never moved or copied.
|
|
54
|
+
if (!operation.resolvedRoots) {
|
|
55
|
+
operation.resolvedRoots = await projectRoots(rpc, roots);
|
|
56
|
+
if (!operation.resolvedRoots.length) {
|
|
57
|
+
const locations = await (dependencies.locations || projectLocations)();
|
|
58
|
+
await rpc('fs/createDirectory', { path: locations.defaultParent, recursive: true });
|
|
59
|
+
const entries = (await rpc('fs/readDirectory', { path: locations.defaultParent })).entries;
|
|
60
|
+
const names = new Set(entries.map(entry => entry.fileName.toLowerCase()));
|
|
61
|
+
const base = defaultName(name);
|
|
62
|
+
let folder = base;
|
|
63
|
+
for (let suffix = 2; names.has(folder.toLowerCase()); suffix++) folder = `${base} ${suffix}`;
|
|
64
|
+
operation.defaultDirectory = path.join(locations.defaultParent, folder);
|
|
65
|
+
operation.resolvedRoots = [{ path: operation.defaultDirectory }];
|
|
66
|
+
}
|
|
67
|
+
save();
|
|
68
|
+
}
|
|
69
|
+
if (operation.defaultDirectory && !operation.directoryCreated) {
|
|
70
|
+
if (operation.directoryAttempted) {
|
|
71
|
+
// On an explicit retry, keep the originally allocated directory. Never
|
|
72
|
+
// allocate another name after an interrupted createDirectory response.
|
|
73
|
+
await projectRoots(rpc, operation.resolvedRoots);
|
|
74
|
+
} else {
|
|
75
|
+
operation.directoryAttempted = true; save();
|
|
76
|
+
await rpc('fs/createDirectory', { path: operation.defaultDirectory, recursive: false });
|
|
77
|
+
}
|
|
78
|
+
operation.directoryCreated = true; save();
|
|
79
|
+
try {
|
|
80
|
+
const result = await rpc('command/exec', { command: ['git', 'init'], cwd: operation.defaultDirectory, timeoutMs: 10000 });
|
|
81
|
+
if (result.exitCode !== 0) throw new Error(result.stderr || result.stdout || `退出码 ${result.exitCode}`);
|
|
82
|
+
} catch (error) { operation.gitWarning = `项目目录已创建,Git 初始化未完成:${error.message}`; save(); }
|
|
83
|
+
}
|
|
84
|
+
const resolvedRoots = await projectRoots(rpc, operation.resolvedRoots);
|
|
85
|
+
if (!operation.projectId) {
|
|
86
|
+
operation.createAttempted = true; save();
|
|
87
|
+
// Native project/create owns idempotency; a retry uses this exact key
|
|
88
|
+
// and original roots on the same server, never a second transport.
|
|
89
|
+
const created = await rpc('project/create', { name, roots: resolvedRoots, idempotencyKey: body.idempotencyKey });
|
|
90
|
+
operation.projectId = created.project.id; save();
|
|
91
|
+
}
|
|
92
|
+
const { project } = await rpc('project/read', { projectId: operation.projectId });
|
|
93
|
+
if (project.id !== operation.projectId || project.name !== name || project.roots.length !== resolvedRoots.length
|
|
94
|
+
|| project.roots.some((root, index) => pathKey(root.path) !== pathKey(resolvedRoots[index].path))) {
|
|
95
|
+
throw new Error('已创建项目的名称或目录发生变化,未覆盖其内容。');
|
|
96
|
+
}
|
|
97
|
+
if (!operation.ordered) {
|
|
98
|
+
const first = (await rpc('project/list', { limit: 1, sortKey: 'position', sortDirection: 'asc' })).data[0];
|
|
99
|
+
if (first && first.id !== project.id) await rpc('project/move', { projectId: project.id, beforeProjectId: first.id });
|
|
100
|
+
operation.ordered = true; save();
|
|
101
|
+
}
|
|
102
|
+
const desktopRegistration = await desktop.register(project, operation, save);
|
|
103
|
+
operation.result = { project: projectSummary(project), desktopRegistration,
|
|
104
|
+
...(operation.gitWarning ? { warning: operation.gitWarning } : {}) };
|
|
105
|
+
save();
|
|
106
|
+
return operation.result;
|
|
107
|
+
});
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (operation.projectId || operation.createAttempted || operation.directoryAttempted) {
|
|
110
|
+
error.details = { ...error.details, projectCreationPending: true,
|
|
111
|
+
...(operation.projectId ? { createdProjectId: operation.projectId } : {}) };
|
|
112
|
+
error.message += operation.projectId
|
|
113
|
+
? ' 项目已创建,请继续完成桌面登记;会沿用同一项目,不会重复创建。'
|
|
114
|
+
: ' 请继续核对本次创建;会沿用原请求标识和目录。';
|
|
115
|
+
} else error.details = { ...error.details, projectCreationPending: false };
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
const fail = (message, statusCode = 400) => { throw Object.assign(new Error(message), { statusCode }); };
|
|
4
|
+
const pathKey = value => process.platform === 'win32' ? value.toLowerCase() : value;
|
|
5
|
+
|
|
6
|
+
function absoluteDirectory(value) {
|
|
7
|
+
if (typeof value !== 'string' || !value.trim() || !path.isAbsolute(value.trim())) fail('请输入电脑上的绝对文件夹路径。');
|
|
8
|
+
return path.normalize(value.trim());
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function directory(rpc, value) {
|
|
12
|
+
const target = absoluteDirectory(value);
|
|
13
|
+
const stat = await rpc('fs/getMetadata', { path: target });
|
|
14
|
+
if (!stat.isDirectory) fail(`此路径不是文件夹:${target}`);
|
|
15
|
+
return target;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function folderName(value) {
|
|
19
|
+
if (typeof value !== 'string' || !value.trim() || /[<>:"/\\|?*\x00-\x1f]/.test(value) || /[ .]$/.test(value)
|
|
20
|
+
|| /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(value) || ['.', '..'].includes(value)) {
|
|
21
|
+
fail('文件夹名称无效,请勿使用路径分隔符或系统保留名称。');
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function projectRoots(rpc, roots) {
|
|
27
|
+
if (!Array.isArray(roots)) fail('项目文件夹列表无效。');
|
|
28
|
+
const result = [], seen = new Set();
|
|
29
|
+
for (const root of roots) {
|
|
30
|
+
const target = await directory(rpc, root?.path);
|
|
31
|
+
const key = pathKey(target);
|
|
32
|
+
if (seen.has(key)) fail(`重复的项目文件夹:${target}`);
|
|
33
|
+
seen.add(key); result.push({ path: target });
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function projectSummary(project) {
|
|
39
|
+
return { projectId: project.id, name: project.name, label: project.name, roots: project.roots,
|
|
40
|
+
path: project.roots[0]?.path || '', cwd: project.roots[0]?.path || '', hostId: 'local' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function browseProjectFolders(rpc, locations, value) {
|
|
44
|
+
const target = await directory(rpc, value || locations.home);
|
|
45
|
+
const entries = (await rpc('fs/readDirectory', { path: target })).entries
|
|
46
|
+
.filter(entry => entry.isDirectory).map(entry => ({ name: entry.fileName, path: path.join(target, entry.fileName) }))
|
|
47
|
+
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true }));
|
|
48
|
+
return { path: target, parent: path.dirname(target) === target ? null : path.dirname(target), entries, ...locations };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function createProjectFolder(rpc, body) {
|
|
52
|
+
const parent = await directory(rpc, body.parent);
|
|
53
|
+
const name = folderName(body.name);
|
|
54
|
+
const entries = (await rpc('fs/readDirectory', { path: parent })).entries;
|
|
55
|
+
if (entries.some(entry => pathKey(entry.fileName) === pathKey(name))) fail('同名文件夹已存在,请选择它或使用其他名称。', 409);
|
|
56
|
+
const target = path.join(parent, name);
|
|
57
|
+
await rpc('fs/createDirectory', { path: target, recursive: false });
|
|
58
|
+
return { path: target };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function saveProject(rpc, locations, body, projectId = null) {
|
|
62
|
+
// Native creation and Desktop registration are coordinated by project-create.mjs.
|
|
63
|
+
if (!projectId) fail('新建项目必须通过工作站项目创建流程。', 503);
|
|
64
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
65
|
+
if (!name) fail('请输入项目名称。');
|
|
66
|
+
const roots = await projectRoots(rpc, body.roots);
|
|
67
|
+
const result = await rpc('project/update', { name, roots, projectId });
|
|
68
|
+
return { project: projectSummary(result.project) };
|
|
69
|
+
}
|