@fxz26284407/yuanma-gateway 0.1.8 → 0.1.10

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/dist/codex.js CHANGED
@@ -28,7 +28,7 @@ export class CodexBackend {
28
28
  directoryScopes = new Map();
29
29
  nativeProjects = new Set();
30
30
  directoryId(path) { return 'codex-directory:' + createHash('sha256').update(path).digest('hex'); }
31
- nativeProjectId(id) { if (this.directoryScopes.has(id))
31
+ nativeProjectId(id) { if (id === 'codex:default' || this.directoryScopes.has(id))
32
32
  return null; requireValue(!platform.codex.directoryScopes || this.nativeProjects.has(id), 'PROJECT_FORBIDDEN', '不是当前 Codex 目录或项目', 403); return id; }
33
33
  ready = false;
34
34
  permissionSelections = new Map();
@@ -178,7 +178,17 @@ export class CodexBackend {
178
178
  if (this.directoryScopes.has(id))
179
179
  return id;
180
180
  }
181
- return null;
181
+ if (legacy)
182
+ return null;
183
+ const defaults = this.projects.find(p => p.id === 'codex:default');
184
+ if (!defaults)
185
+ return null;
186
+ const cwd = typeof thread.cwd === 'string' ? await realpath(thread.cwd).catch(() => null) : null;
187
+ if (!cwd)
188
+ return null;
189
+ if (!defaults.roots.includes(cwd))
190
+ defaults.roots.push(cwd);
191
+ return defaults.id;
182
192
  }
183
193
  async inspect(projectId, threadId) {
184
194
  if (this.fresh.has(threadId))
@@ -489,12 +499,21 @@ export class CodexBackend {
489
499
  requireValue(false, 'INVALID_REQUEST', 'Unknown thread action', 400);
490
500
  return this.threadMeta(projectId, threadId);
491
501
  }
502
+ async navigationThreads(cursor, limit = 100) {
503
+ return this.call('thread/list', { limit, useStateDbOnly: true, sortKey: 'updated_at', ...(cursor && cursor !== 'start' ? { cursor } : {}) });
504
+ }
492
505
  async navigation(grants) {
493
- const [projects, sections, threads, state] = await Promise.all([this.call('project/list', { limit: 100 }), this.call('threadSection/list', { limit: 100 }), this.call('thread/list', { limit: 100, useStateDbOnly: true, sortKey: 'updated_at' }), desktopSidebarState()]);
506
+ const [projects, sections, threads, state] = await Promise.all([this.call('project/list', { limit: 100 }), this.call('threadSection/list', { limit: 100 }), this.navigationThreads(), desktopSidebarState()]);
494
507
  await this.desktopSync?.recover(projects.data);
495
- const directoryProjects = this.projects.filter(p => this.directoryScopes.has(p.id));
496
- const assigned = platform.codex.directoryScopes ? await Promise.all(threads.data.map(async (t) => ({ ...t, projectId: await this.assignedProject(t) }))) : threads.data;
497
- return { ...buildSidebar([...projects.data, ...directoryProjects], sections.data, assigned, state, grants, this.desktopSync?.placements()), desktopSync: this.desktopSync?.status(grants) };
508
+ const directoryProjects = this.projects.filter(p => p.id === 'codex:default' || this.directoryScopes.has(p.id));
509
+ const assigned = await Promise.all(threads.data.map(async (t) => ({ ...t, projectId: await this.assignedProject(t) })));
510
+ const navigation = buildSidebar([...projects.data, ...directoryProjects], sections.data, assigned, state, grants, this.desktopSync?.placements());
511
+ const defaults = navigation.projects.find(p => p.id === 'codex:default');
512
+ if (defaults) {
513
+ defaults.threads = [];
514
+ Object.assign(defaults, { nextCursor: 'start' });
515
+ }
516
+ return { ...navigation, desktopSync: this.desktopSync?.status(grants) };
498
517
  }
499
518
  async manageProject(projectId, action, value) {
500
519
  requireValue(this.projects.some(p => p.id === projectId), 'PROJECT_FORBIDDEN', '没有此项目的操作权限', 403);
@@ -541,11 +560,9 @@ export class CodexBackend {
541
560
  } while (cursor && projects.length < 2000);
542
561
  this.nativeProjects = new Set(projects.map(p => p.id));
543
562
  if (platform.codex.directoryScopes) {
544
- const directories = new Map();
545
- let seen = 0;
546
- cursor = undefined;
547
- do {
548
- const page = await this.call('thread/list', { limit: 100, useStateDbOnly: true, ...(cursor ? { cursor } : {}) });
563
+ const directories = new Map(this.directoryScopes);
564
+ {
565
+ const page = await this.navigationThreads();
549
566
  for (const thread of page.data) {
550
567
  if (thread.projectId || typeof thread.cwd !== 'string')
551
568
  continue;
@@ -553,13 +570,21 @@ export class CodexBackend {
553
570
  if (path)
554
571
  directories.set(this.directoryId(path), path);
555
572
  }
556
- seen += page.data.length;
557
- cursor = page.nextCursor ?? undefined;
558
- } while (cursor && seen < 2000);
573
+ }
559
574
  this.directoryScopes = directories;
560
575
  for (const [id, path] of directories)
561
576
  projects.push({ id, name: basename(path) || path, roots: [path] });
562
577
  }
578
+ const roots = [...(this.projects.find(p => p.id === 'codex:default')?.roots ?? [])];
579
+ const state = await desktopSidebarState();
580
+ for (const thread of (await this.navigationThreads()).data) {
581
+ if (thread.projectId || state.assignments[thread.id]?.projectId || typeof thread.cwd !== 'string')
582
+ continue;
583
+ const path = await realpath(thread.cwd).catch(() => null);
584
+ if (path && !roots.includes(path))
585
+ roots.push(path);
586
+ }
587
+ projects.push({ id: 'codex:default', name: '默认项目', roots: roots.length ? roots : [...(projects[0]?.roots ?? this.projects[0]?.roots ?? [])] });
563
588
  return projects;
564
589
  }
565
590
  replaceProjects(projects) { this.projects.splice(0, this.projects.length, ...projects); }
@@ -675,11 +700,11 @@ export class CodexBackend {
675
700
  await this.authorize(projectId, result.thread.id);
676
701
  return { id: result.thread.id, name: result.thread.name ?? '新会话', status: result.thread.status?.type ?? 'idle' };
677
702
  }
678
- async listThreads(projectId) {
703
+ async listThreads(projectId, cursor) {
679
704
  const project = this.projects.find(p => p.id === projectId);
680
705
  requireValue(project, 'PROJECT_FORBIDDEN', 'Project is not locally authorized', 403);
681
706
  const roots = await Promise.all(project.roots.map(p => realpath(p)));
682
- const page = await this.call('thread/list', { limit: 100, useStateDbOnly: true, sortKey: 'updated_at' });
707
+ const page = await this.navigationThreads(cursor, projectId === 'codex:default' ? 20 : 100);
683
708
  const data = [];
684
709
  for (const thread of page.data) {
685
710
  if (await this.assignedProject(thread) !== projectId)
@@ -687,7 +712,7 @@ export class CodexBackend {
687
712
  const cwd = await realpath(thread.cwd).catch(() => null);
688
713
  if (!cwd)
689
714
  continue;
690
- if (!roots.some(root => { const r = relative(root, cwd); return r === '' || (!isAbsolute(r) && r !== '..' && !r.startsWith(`..${sep}`)); }))
715
+ if (projectId !== 'codex:default' && !roots.some(root => { const r = relative(root, cwd); return r === '' || (!isAbsolute(r) && r !== '..' && !r.startsWith(`..${sep}`)); }))
691
716
  continue;
692
717
  data.push({ id: thread.id, name: threadDisplayName(thread), status: thread.status?.type ?? 'notLoaded', model: thread.model ?? null });
693
718
  }
package/dist/deepseek.js CHANGED
@@ -538,7 +538,7 @@ export class DeepSeekBackend {
538
538
  await this.inspect(projectId, id);
539
539
  requireValue(serviceTier === undefined, 'INVALID_MODEL', 'Harness 没有 Fast 档位', 400);
540
540
  const entry = (await this.listModels()).find(m => m.model === model);
541
- requireValue(entry && (entry.efforts.length ? entry.efforts.includes(effort) : effort === ''), 'INVALID_MODEL', '模型或思考深度不可用', 400);
541
+ requireValue(entry && (entry.efforts.includes(effort) || (effort === '' && entry.defaultEffort === '')), 'INVALID_MODEL', '模型或思考深度不可用', 400);
542
542
  const [provider, nativeModel] = JSON.parse(entry.model);
543
543
  const result = await this.call('session/selectModel', { request: { sessionId: id, provider, model: nativeModel, ...(effort ? { reasoningEffort: effort } : {}) } });
544
544
  const applied = this.selectionView(result.selected);
@@ -0,0 +1,163 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { readFile, writeFile, rename, lstat, unlink, open } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { join, isAbsolute } from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { requireValue } from './domain.js';
8
+ const execute = promisify(execFile);
9
+ const statePath = join(homedir(), '.codex/.codex-global-state.json');
10
+ async function desktopRunning() {
11
+ const { stdout } = await execute('/bin/ps', ['-axo', 'comm=']);
12
+ return stdout.split('\n').some(p => /\/(?:ChatGPT|Codex)\.app\/Contents\/MacOS\/(?:ChatGPT|Codex)$/.test(p.trim()));
13
+ }
14
+ async function regularFile(path, optional = false) {
15
+ const st = await lstat(path).catch(e => { if (optional && e.code === 'ENOENT')
16
+ return null; throw e; });
17
+ requireValue(!st || (st.isFile() && !st.isSymbolicLink() && st.nlink === 1 && st.uid === process.getuid?.()), 'UNSAFE_STORAGE', '桌面同步文件必须由当前用户持有,且不能是符号链接');
18
+ return st;
19
+ }
20
+ async function replaceFile(path, value, mode = 0o600) {
21
+ const temp = path + '.' + randomUUID() + '.tmp';
22
+ try {
23
+ const f = await open(temp, 'wx', mode);
24
+ try {
25
+ await f.writeFile(value);
26
+ await f.sync();
27
+ }
28
+ finally {
29
+ await f.close();
30
+ }
31
+ await rename(temp, path);
32
+ }
33
+ finally {
34
+ await unlink(temp).catch(e => { if (e.code !== 'ENOENT')
35
+ throw e; });
36
+ }
37
+ }
38
+ // Only project registration, names, order and section membership change. All other state stays intact.
39
+ export function applyDesktopProjectChanges(state, changes) {
40
+ const next = structuredClone(state), identity = 'local:' + join(homedir(), '.codex');
41
+ const projects = next['local-projects'] ??= {};
42
+ const mappings = next['app-server-project-id-by-legacy-project-id-by-host'] ??= {};
43
+ const mapping = mappings[identity] ??= {};
44
+ const atoms = next['electron-persisted-atom-state'] ??= {};
45
+ const order = next['project-order'] ??= [];
46
+ const sidebarOrder = atoms['unified-sidebar-project-order-v1'] ??= [];
47
+ for (const change of changes) {
48
+ requireValue(/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(change.id) && change.name.trim().length > 0 && change.roots.length > 0 && change.roots.every(isAbsolute), 'INVALID_PROJECT', '待同步的项目记录不完整');
49
+ const id = Object.keys(mapping).find(id => mapping[id] === change.id && projects[id]) ?? change.id;
50
+ requireValue(!mapping[id] || mapping[id] === change.id, 'PROJECT_CONFLICT', '桌面项目映射已变化,请重新核对');
51
+ const deletions = next['app-server-pending-project-deletions-by-host']?.[identity] ?? {};
52
+ requireValue(!deletions[id] && !Object.values(deletions).some((d) => d.projectId === change.id), 'PROJECT_CONFLICT', '此项目正在桌面删除,请先在桌面处理');
53
+ mapping[id] = change.id;
54
+ if (!projects[id])
55
+ projects[id] = { id, name: change.name, rootPaths: change.roots, createdAt: Date.now(), updatedAt: Date.now() };
56
+ else if (change.rename && projects[id].name !== change.name)
57
+ projects[id] = { ...projects[id], name: change.name, updatedAt: Date.now() };
58
+ if (!order.includes(id))
59
+ order.push(id);
60
+ const key = 'codex:project:' + id;
61
+ if (!sidebarOrder.includes(key))
62
+ sidebarOrder.push(key);
63
+ if (change.sectionId === undefined)
64
+ continue;
65
+ const scopes = Object.values(atoms['sidebar-custom-sections-v3'] ?? {});
66
+ const belongs = (k) => k.startsWith('codex:project:') && (mapping[k.slice(14)] ?? k.slice(14)) === change.id;
67
+ const matches = scopes.filter(s => s.sections?.some((g) => change.sectionId ? g.hostSectionIds?.local === change.sectionId : (g.itemKeys ?? []).some(belongs)));
68
+ requireValue(change.sectionId ? matches.length === 1 : matches.length <= 1, 'SECTION_CONFLICT', '桌面分区已变化或存在多个账号归属,请重新选择分区');
69
+ for (const scope of matches)
70
+ for (const group of scope.sections) {
71
+ group.itemKeys = (group.itemKeys ?? []).filter((k) => !belongs(k));
72
+ if (change.sectionId && group.hostSectionIds?.local === change.sectionId)
73
+ group.itemKeys.push(key);
74
+ }
75
+ }
76
+ return next;
77
+ }
78
+ export class DesktopProjectSync {
79
+ directory;
80
+ journal = { version: 1, pending: {}, imported: [], appliedAt: 0 };
81
+ work = Promise.resolve();
82
+ timer;
83
+ problem = '';
84
+ closed = false;
85
+ constructor(directory) {
86
+ this.directory = directory;
87
+ }
88
+ get path() { return join(this.directory, 'desktop-project-sync.json'); }
89
+ run(action) { const result = this.work.then(action); this.work = result.then(() => { }, () => { }); return result; }
90
+ async start() {
91
+ await regularFile(this.path, true);
92
+ try {
93
+ const value = JSON.parse(await readFile(this.path, 'utf8'));
94
+ requireValue(value.version === 1 && value.pending && Array.isArray(value.imported) && typeof value.appliedAt === 'number', 'UNSAFE_STORAGE', '无法读取桌面待同步记录');
95
+ this.journal = value;
96
+ }
97
+ catch (e) {
98
+ if (e.code !== 'ENOENT')
99
+ throw e;
100
+ }
101
+ // ponytail: one queue serializes this small state file; separate locks only if multiple writers are introduced.
102
+ this.timer = setInterval(() => { if (Object.keys(this.journal.pending).length)
103
+ this.flush().catch(e => { this.problem = e instanceof Error ? e.message : '桌面同步失败,请刷新后重试'; }); }, 2000);
104
+ this.timer.unref();
105
+ }
106
+ async persist(value = this.journal) { await regularFile(this.path, true); await replaceFile(this.path, JSON.stringify(value)); }
107
+ enqueue(change) {
108
+ return this.run(async () => {
109
+ const next = structuredClone(this.journal);
110
+ next.pending[change.id] = { ...next.pending[change.id], ...change, rename: next.pending[change.id]?.rename || change.rename };
111
+ if (!next.imported.includes(change.id))
112
+ next.imported.push(change.id);
113
+ await this.persist(next);
114
+ this.journal = next;
115
+ this.problem = '';
116
+ });
117
+ }
118
+ recover(projects) {
119
+ return this.run(async () => {
120
+ let changed = false;
121
+ const next = structuredClone(this.journal);
122
+ for (const p of projects) {
123
+ const sectionId = p.metadata?.['mobile.sectionId'];
124
+ if (typeof sectionId !== 'string' || next.imported.includes(p.id))
125
+ continue;
126
+ if (!next.pending[p.id])
127
+ next.pending[p.id] = { id: p.id, name: p.name, roots: p.roots.map((r) => r.path), sectionId };
128
+ next.imported.push(p.id);
129
+ changed = true;
130
+ }
131
+ if (changed) {
132
+ await this.persist(next);
133
+ this.journal = next;
134
+ }
135
+ });
136
+ }
137
+ status(grants) { const pending = Object.values(this.journal.pending).filter(p => !!grants[p.id]); return { pending: pending.length, appliedAt: this.journal.appliedAt, error: pending.length ? this.problem : '' }; }
138
+ placements() { return Object.values(this.journal.pending).filter(p => p.sectionId !== undefined).map(p => ({ id: p.id, sectionId: p.sectionId })); }
139
+ flush() {
140
+ return this.run(async () => {
141
+ if (this.closed || !Object.keys(this.journal.pending).length || await desktopRunning())
142
+ return;
143
+ const stat = (await regularFile(statePath));
144
+ await regularFile(statePath + '.bak', true);
145
+ const original = await readFile(statePath, 'utf8'), state = JSON.parse(original);
146
+ requireValue(state && typeof state === 'object' && !Array.isArray(state), 'UNSAFE_STORAGE', '桌面状态格式无效');
147
+ const updated = JSON.stringify(applyDesktopProjectChanges(state, Object.values(this.journal.pending)));
148
+ const backup = join(this.directory, 'desktop-state-before-sync-' + Date.now() + '.json');
149
+ await writeFile(backup, original, { flag: 'wx', mode: 0o600 });
150
+ // Desktop must stay closed until the phone reports completion; it does not reload external file changes.
151
+ if (this.closed || await desktopRunning() || await readFile(statePath, 'utf8') !== original)
152
+ return;
153
+ await replaceFile(statePath, updated, stat.mode & 0o777);
154
+ await replaceFile(statePath + '.bak', updated, 0o600);
155
+ const next = { ...this.journal, pending: {}, appliedAt: Date.now() };
156
+ await this.persist(next);
157
+ this.journal = next;
158
+ this.problem = '';
159
+ });
160
+ }
161
+ async close() { this.closed = true; if (this.timer)
162
+ clearInterval(this.timer); await this.work; }
163
+ }
@@ -42,7 +42,10 @@ export class ProjectAccess {
42
42
  return;
43
43
  this.syncing = (async () => {
44
44
  const results = await Promise.allSettled(this.catalogBackends.filter(b => b.projectCatalog).map(b => b.projectCatalog()));
45
- const catalog = results.flatMap(r => r.status === 'fulfilled' ? r.value : []), next = this.projects.map(p => ({ ...p, name: catalog.find(c => c.id === p.id)?.name ?? p.name })), seen = new Set(next.map(p => p.id));
45
+ const catalog = results.flatMap(r => r.status === 'fulfilled' ? r.value : []), next = this.projects.map(p => {
46
+ const current = catalog.find(c => c.id === p.id);
47
+ return { ...p, name: current?.name ?? p.name, roots: p.id === 'codex:default' && current ? current.roots : p.roots };
48
+ }), seen = new Set(next.map(p => p.id));
46
49
  for (const p of catalog) {
47
50
  if (seen.has(p.id))
48
51
  continue;
package/dist/server.js CHANGED
@@ -647,7 +647,7 @@ export async function startGateway(options) {
647
647
  const projectId = text(data.projectId, 'projectId');
648
648
  allowed(projectId, actor, true);
649
649
  requireValue(current().backend.setModel, 'UNSUPPORTED_COMMAND', 'Model settings unavailable', 503);
650
- return current().backend.setModel(projectId, text(data.threadId, 'threadId'), text(data.model, 'model'), text(data.effort, 'effort'), data.serviceTier);
650
+ return current().backend.setModel(projectId, text(data.threadId, 'threadId'), text(data.model, 'model'), data.effort === '' ? '' : text(data.effort, 'effort'), data.serviceTier);
651
651
  }
652
652
  if (req.method === 'POST' && path === '/v1/questions/reply') {
653
653
  const projectId = text(data.projectId, 'projectId');
@@ -677,7 +677,7 @@ export async function startGateway(options) {
677
677
  const projectId = text(url.searchParams.get('projectId'), 'projectId');
678
678
  allowed(projectId, actor);
679
679
  requireValue(current().backend.listThreads, 'UNSUPPORTED_COMMAND', 'Thread catalog unavailable', 503);
680
- return current().backend.listThreads(projectId);
680
+ return current().backend.listThreads(projectId, url.searchParams.get('cursor') ?? undefined);
681
681
  }
682
682
  if (req.method === 'GET' && url.pathname === '/v1/approvals') {
683
683
  const projectId = text(url.searchParams.get('projectId'), 'projectId');
@@ -817,7 +817,7 @@ export async function startGateway(options) {
817
817
  const projectId = text(url.searchParams.get('projectId'), 'projectId');
818
818
  allowed(projectId);
819
819
  requireValue(current().backend.listThreads, 'UNSUPPORTED_COMMAND', 'Thread catalog unavailable', 503);
820
- send(res, 200, { requestId, data: await current().backend.listThreads(projectId) });
820
+ send(res, 200, { requestId, data: await current().backend.listThreads(projectId, url.searchParams.get('cursor') ?? undefined) });
821
821
  return;
822
822
  }
823
823
  const opMatch = url.pathname.match(/^\/v1\/operations\/([^/]+)$/);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fxz26284407/yuanma-gateway",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": "22.x"