ai_co_discuss 0.3.71 → 0.3.72
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/backend/free_room.py +81 -9
- package/backend/schema.sql +1675 -1645
- package/backend/server.py +2643 -2638
- package/docs/runner.md +12 -1
- package/package.json +1 -1
- package/runner/adapters/antigravity_cli.mjs +37 -7
- package/runner/adapters/api.mjs +25 -4
- package/runner/adapters/api_files.mjs +36 -19
- package/runner/adapters/claude.mjs +448 -385
- package/runner/adapters/cli.mjs +175 -155
- package/runner/adapters/codex_app.mjs +19 -3
- package/runner/adapters/codex_bridge.mjs +123 -0
- package/runner/adapters/codex_cli.mjs +47 -10
- package/runner/adapters/index.mjs +34 -2
- package/runner/adapters/profiles.mjs +5 -3
- package/runner/device_auth.mjs +39 -21
- package/runner/discussion_launch.mjs +83 -21
- package/runner/discussion_local.mjs +2 -0
- package/runner/discussion_runner.mjs +34 -1
- package/runner/expert_prompts.mjs +21 -11
- package/runner/free_runner.mjs +73 -14
- package/runner/project_files.mjs +57 -5
- package/runner/relay_channel.mjs +4 -3
- package/runner/remote_launch.mjs +268 -268
- package/runner/remote_policy.mjs +2 -1
- package/web/app.js +4955 -4862
- package/web/backup.js +1 -1
- package/web/modern.css +1 -1
- package/web/modern.js +9793 -9187
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* - its static facts in `profiles.mjs` (label, platform, config fields, efforts, access levels, capabilities), plain data
|
|
5
5
|
* the web page reads as well;
|
|
6
6
|
* - its behaviour in its own module, a descriptor with the same shape for every software:
|
|
7
|
-
* - `deliver(config, binding, task, spawn_process, user_home)` → `{session_id, status, capabilities}`, where
|
|
8
|
-
* `capabilities` `{stoppable, progress}` says what this one delivery can do
|
|
7
|
+
* - `deliver(config, binding, task, spawn_process, user_home)` → `{session_id, status, capabilities, steer?}`, where
|
|
8
|
+
* `capabilities` `{stoppable, progress}` says what this one delivery can do, and `steer(text)` (when the running
|
|
9
|
+
* software can take an interjection without ending its turn) passes one in, resolving false once the turn is over;
|
|
9
10
|
* - `session`: `{path, exists, branch, settings}` for the software's own session store, or null;
|
|
10
11
|
* - `models`: `{list, effort?, defaults}` read from the installed software, or null when it is chosen inside the App;
|
|
11
12
|
* - optional `receipt(config, id)` for deliveries confirmed later (`status: 'sidecar_pending'`),
|
|
@@ -80,6 +81,37 @@ export function stored_delivery(value) {
|
|
|
80
81
|
return Object.hasOwn(legacy, value) ? legacy[value] : JSON.parse(value);
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
// What one execution-detail answer carries: the latest entries, each body cut to a readable length.
|
|
85
|
+
const DETAIL_ENTRY_LIMIT = 300;
|
|
86
|
+
const DETAIL_BODY_LIMIT = 4000;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What an agent did in one execution, rebuilt from the raw event log its adapter's process wrote: messages, commands
|
|
90
|
+
* with their output, searches, edits and tool calls in order. Each adapter's `events.detail(event)` turns one event into
|
|
91
|
+
* entries `{id?, kind, title?, body?, append?, state?}`; an entry with a known id updates the earlier one in place
|
|
92
|
+
* (a command's result fills in its start), `append` extends its body. Returns null when the adapter keeps no such log.
|
|
93
|
+
*/
|
|
94
|
+
export function execution_details(adapter_id, text) {
|
|
95
|
+
const detail = adapter_of(adapter_id)?.events?.detail;
|
|
96
|
+
if (!detail) return null;
|
|
97
|
+
const entries = new Map();
|
|
98
|
+
for (const line of text.split('\n')) {
|
|
99
|
+
let event;
|
|
100
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
101
|
+
// The runner marks where an interjection went into the turn; it reads the same for every software.
|
|
102
|
+
const parts = event.type === 'codiscuss.interjection' ? [{ kind: 'interjection', body: String(event.text || '') }] : detail(event);
|
|
103
|
+
for (const { append, ...fields } of parts || []) {
|
|
104
|
+
const id = fields.id ?? `entry-${entries.size}`;
|
|
105
|
+
const entry = { ...entries.get(id), ...Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)), id };
|
|
106
|
+
if (append) entry.body = (entry.body || '') + append;
|
|
107
|
+
entries.set(id, entry);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const all = [...entries.values()].filter(entry => entry.kind);
|
|
111
|
+
return { total: all.length, entries: all.slice(-DETAIL_ENTRY_LIMIT).map(entry => entry.body?.length > DETAIL_BODY_LIMIT
|
|
112
|
+
? { ...entry, body: entry.body.slice(0, DETAIL_BODY_LIMIT), truncated: true } : entry) };
|
|
113
|
+
}
|
|
114
|
+
|
|
83
115
|
/** Adapters whose sessions this machine can cut or branch; the website lists taken-back executions only for these. */
|
|
84
116
|
export const BRANCHABLE_ADAPTERS = Object.values(ADAPTERS).filter(adapter => adapter.session?.branch).map(adapter => adapter.id);
|
|
85
117
|
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
* (the runner cuts the session), `fresh` (context rebuilt each time) or `kept` (cannot be cut here); `project` (works in
|
|
27
27
|
* a project directory); `access_env` (its process takes the credential file by environment); `enforces_read_only`
|
|
28
28
|
* (its sandbox enforces a read-only project); `ripgrep` (bundles rg for reading on Windows); `file_tools` (works on
|
|
29
|
-
* the project only through the runner's file tools, running no commands)
|
|
29
|
+
* the project only through the runner's file tools, running no commands); `limits_directories` (a directory it may
|
|
30
|
+
* only read stays read-only even while it may write elsewhere and run commands: a sandbox or file tools, not rules
|
|
31
|
+
* that a command could bypass).
|
|
30
32
|
* - `session_family`: profiles sharing one session store; switching among them keeps the conversation.
|
|
31
33
|
* - `windows_command`: how its shell names the codiscuss CLI on Windows.
|
|
32
34
|
* - `task_rules(cli, {runs_commands})`: fixed task text this software needs, added by the runner so no member prompt drops it.
|
|
@@ -45,7 +47,7 @@ export const ADAPTER_PROFILES = {
|
|
|
45
47
|
access_hints: { read: '只读沙箱', write: '项目可写的沙箱,命令在沙箱内运行、可联网',
|
|
46
48
|
auto: '沙箱外的操作由模型申请提权,Codex 自动审核代替人工批准', full: '无沙箱、无审批(--dangerously-bypass-approvals-and-sandbox)' },
|
|
47
49
|
client: 'codex', adopt: 'session', host_existing: true, session_family: 'codex',
|
|
48
|
-
capabilities: { delivery: PROCESS, rewind: 'cut', project: true, access_env: true, enforces_read_only: true, ripgrep: true },
|
|
50
|
+
capabilities: { delivery: PROCESS, rewind: 'cut', project: true, access_env: true, enforces_read_only: true, limits_directories: true, ripgrep: true },
|
|
49
51
|
},
|
|
50
52
|
codex: {
|
|
51
53
|
platform: 'Codex', label: 'Codex App', kind: 'app', location: 'executable', fields: ['source_thread_id', 'project_id', 'project_name'],
|
|
@@ -92,7 +94,7 @@ export const ADAPTER_PROFILES = {
|
|
|
92
94
|
efforts: null, models: false, access_levels: ['read', 'write'], per_tool_allow: false, adopt: '', host_existing: false,
|
|
93
95
|
access_hints: { read: '用接入程序的文件工具读取、搜索项目文件', write: '还可新建、覆盖、局部编辑项目文件;没有命令行' },
|
|
94
96
|
unsupported_hints: { auto: 'API 成员没有命令行,没有需要软件审批的操作', full: 'API 成员没有命令行,没有需要软件审批的操作' },
|
|
95
|
-
capabilities: { delivery: PROCESS, rewind: 'fresh', project: true, file_tools: true },
|
|
97
|
+
capabilities: { delivery: PROCESS, rewind: 'fresh', project: true, file_tools: true, limits_directories: true },
|
|
96
98
|
},
|
|
97
99
|
};
|
|
98
100
|
|
package/runner/device_auth.mjs
CHANGED
|
@@ -35,8 +35,6 @@ const LABEL_LIMIT = 60;
|
|
|
35
35
|
const CODE_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
36
36
|
const CODE_LENGTH = 12;
|
|
37
37
|
const PAIR_CONTEXT = 'codiscuss-pair-v1';
|
|
38
|
-
// What an expired (not yet removed) device may still ask for: only to renew itself.
|
|
39
|
-
const EXPIRED_ACTIONS = new Set(['passkey-challenge', 'device-renew']);
|
|
40
38
|
// What a locked device may still ask for: only to unlock (or renew) itself with its passkey.
|
|
41
39
|
const LOCKED_ACTIONS = new Set(['passkey-challenge', 'device-unlock', 'device-renew']);
|
|
42
40
|
// The one action an unpaired browser may send, unsigned: proving it holds a pairing code.
|
|
@@ -136,25 +134,35 @@ export function machine_fingerprint(link) {
|
|
|
136
134
|
return createHash('sha256').update(`${ecdh.x}.${ecdh.y}.${sign.x}.${sign.y}`).digest('hex').slice(0, 32);
|
|
137
135
|
}
|
|
138
136
|
|
|
139
|
-
/**
|
|
140
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Start a pairing at this computer's own screen: a 12-character code, valid two minutes, and the link a camera can open.
|
|
139
|
+
* `without_passkey` lets this one pairing go without a passkey (a browser that cannot create one); only the person at
|
|
140
|
+
* this computer can allow it, never the new device's page.
|
|
141
|
+
*/
|
|
142
|
+
export function start_pairing(home, { site, fingerprint = '', temporary = false, without_passkey = false, by = 'computer' }) {
|
|
141
143
|
const bytes = randomBytes(CODE_LENGTH);
|
|
142
144
|
const code = Array.from(bytes, byte => CODE_ALPHABET[byte & 31]).join('');
|
|
143
|
-
audit(home, { event: 'pairing.started', by, temporary: Boolean(temporary) });
|
|
145
|
+
audit(home, { event: 'pairing.started', by, temporary: Boolean(temporary), without_passkey: Boolean(without_passkey) });
|
|
144
146
|
return with_lock(home, (registry, now) => {
|
|
145
147
|
const id = randomBytes(8).toString('hex');
|
|
146
|
-
registry.pairings[id] = { code, temporary: Boolean(temporary), by, expires_at: now + PAIRING_MS, failures: 0 };
|
|
148
|
+
registry.pairings[id] = { code, temporary: Boolean(temporary), without_passkey: Boolean(without_passkey), by, expires_at: now + PAIRING_MS, failures: 0 };
|
|
147
149
|
return { code: `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8)}`, expires_at: now + PAIRING_MS, temporary: Boolean(temporary),
|
|
148
|
-
...(site ? { url: `${site}/#pair=${code}${fingerprint ? `&fp=${fingerprint}` : ''}` } : {}) };
|
|
150
|
+
without_passkey: Boolean(without_passkey), ...(site ? { url: `${site}/#pair=${code}${fingerprint ? `&fp=${fingerprint}` : ''}` } : {}) };
|
|
149
151
|
});
|
|
150
152
|
}
|
|
151
153
|
|
|
152
|
-
/**
|
|
154
|
+
/** Whether a device may renew itself with its passkey after expiring: kept devices with a passkey only. */
|
|
155
|
+
const renewable = device => device.passkeys.length > 0 && !device.temporary;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Record a paired device with its passkey, if it has one: thirty days, or one day for a temporary one. A device paired
|
|
159
|
+
* without a passkey (allowed at this computer for that pairing) is trusted by its device key alone and never locks.
|
|
160
|
+
*/
|
|
153
161
|
function add_device(registry, now, { public_key, label, temporary, by, passkey }) {
|
|
154
162
|
const id = device_id_of(public_key);
|
|
155
163
|
registry.devices[id] = { public_key: { kty: 'EC', crv: 'P-256', x: public_key.x, y: public_key.y }, label: clean_label(label), by,
|
|
156
|
-
temporary: Boolean(temporary), created_at: now, expires_at: now + (temporary ? TEMPORARY_DAYS : DEVICE_DAYS) * DAY_MS, passkeys: [passkey],
|
|
157
|
-
// Pairing itself was confirmed
|
|
164
|
+
temporary: Boolean(temporary), created_at: now, expires_at: now + (temporary ? TEMPORARY_DAYS : DEVICE_DAYS) * DAY_MS, passkeys: passkey ? [passkey] : [],
|
|
165
|
+
// Pairing itself was confirmed at this computer, so the device starts unlocked.
|
|
158
166
|
unlocked_until: Math.min(now + UNLOCK_MS, now + (temporary ? TEMPORARY_DAYS : DEVICE_DAYS) * DAY_MS) };
|
|
159
167
|
return { device_id: id, expires_at: registry.devices[id].expires_at, temporary: Boolean(temporary) };
|
|
160
168
|
}
|
|
@@ -165,7 +173,6 @@ function add_device(registry, now, { public_key, label, temporary, by, passkey }
|
|
|
165
173
|
*/
|
|
166
174
|
export function pair_device(home, machine_id, site, { public_key, label, proof, passkey: credential }) {
|
|
167
175
|
if (typeof proof !== 'string' || !proof) throw new Error('缺少配对证明');
|
|
168
|
-
if (!credential) throw new Error('配对须同时创建这台设备的通行密钥');
|
|
169
176
|
device_id_of(public_key);
|
|
170
177
|
const { origin, rp_id } = relying_party(site);
|
|
171
178
|
const paired = with_lock(home, (registry, now) => {
|
|
@@ -173,10 +180,14 @@ export function pair_device(home, machine_id, site, { public_key, label, proof,
|
|
|
173
180
|
for (const [id, pairing] of Object.entries(registry.pairings)) {
|
|
174
181
|
const expected = Buffer.from(pairing_proof(pairing.code, machine_id, public_key));
|
|
175
182
|
if (expected.length === supplied.length && timingSafeEqual(expected, supplied)) {
|
|
183
|
+
// Going without a passkey drops the daily confirmation, so only the person at this computer may allow it.
|
|
184
|
+
if (!credential && !pairing.without_passkey) {
|
|
185
|
+
throw Object.assign(new Error('这台设备没有创建通行密钥。如果它的浏览器确实不支持,请在电脑上添加设备时勾选“允许不用通行密钥”,再重新扫码'), { need: 'passkey' });
|
|
186
|
+
}
|
|
176
187
|
delete registry.pairings[id];
|
|
177
|
-
const passkey = verify_registration(credential, { challenge: pairing_passkey_challenge(pairing.code, public_key), origin, rp_id });
|
|
188
|
+
const passkey = credential && verify_registration(credential, { challenge: pairing_passkey_challenge(pairing.code, public_key), origin, rp_id });
|
|
178
189
|
const added = add_device(registry, now, { public_key, label, temporary: pairing.temporary, by: pairing.by, passkey });
|
|
179
|
-
audit(home, { event: 'device.paired', device: added.device_id, label: clean_label(label), by: pairing.by, temporary: pairing.temporary });
|
|
190
|
+
audit(home, { event: 'device.paired', device: added.device_id, label: clean_label(label), by: pairing.by, temporary: pairing.temporary, passkey: Boolean(passkey) });
|
|
180
191
|
return added;
|
|
181
192
|
}
|
|
182
193
|
}
|
|
@@ -206,29 +217,36 @@ export async function open_signed_request(home, id, sealed, machine_id) {
|
|
|
206
217
|
if (!valid) throw new Error('设备签名无效');
|
|
207
218
|
// The signature names the computer it was meant for, so a request cannot be passed on to another paired computer.
|
|
208
219
|
if (request.machine !== machine_id) throw new Error('这条请求是发给另一台电脑的');
|
|
209
|
-
|
|
210
|
-
|
|
220
|
+
// An expired device may only renew itself, and only if it can: the website lets it through for that alone, so the
|
|
221
|
+
// limit is checked again here rather than trusted.
|
|
222
|
+
if (device.expires_at <= Date.now()) {
|
|
223
|
+
if (!renewable(device)) throw Object.assign(new Error('设备授权已到期,请在电脑前重新配对'), { need: 'pairing' });
|
|
224
|
+
const renewing = request.action === 'device-renew' || (request.action === 'passkey-challenge' && request.payload?.purpose === 'renew');
|
|
225
|
+
if (!renewing) throw Object.assign(new Error('设备授权已到期,请用通行密钥续期'), { need: 'renew' });
|
|
211
226
|
}
|
|
212
|
-
|
|
227
|
+
// A device without a passkey has nothing to unlock with, so it skips the daily confirmation.
|
|
228
|
+
if (device.passkeys.length > 0 && !(device.unlocked_until > Date.now()) && !LOCKED_ACTIONS.has(request.action)) {
|
|
213
229
|
throw Object.assign(new Error('请用通行密钥确认这台设备(每天一次)'), { need: 'unlock' });
|
|
214
230
|
}
|
|
215
231
|
return { request, device_id: sealed.device_id };
|
|
216
232
|
}
|
|
217
233
|
|
|
218
234
|
/**
|
|
219
|
-
* The device list this computer signs for the website: each device's public key and
|
|
220
|
-
*
|
|
235
|
+
* The device list this computer signs for the website: each device's public key, expiry and, for a device that may renew
|
|
236
|
+
* itself, how long after expiry it may still reach this computer to do so. The website checks requests against it and
|
|
237
|
+
* cannot add a device of its own.
|
|
221
238
|
*/
|
|
222
239
|
export async function signed_device_list(home, link, now = Date.now()) {
|
|
223
240
|
const { devices } = read_registry(home);
|
|
224
241
|
const body = JSON.stringify({ machine: link.id, issued_at: now, devices: Object.entries(devices)
|
|
225
|
-
.map(([id, device]) => ({ id, x: device.public_key.x, y: device.public_key.y, expires_at: device.expires_at
|
|
242
|
+
.map(([id, device]) => ({ id, x: device.public_key.x, y: device.public_key.y, expires_at: device.expires_at,
|
|
243
|
+
...(renewable(device) ? { renew_until: device.expires_at + RENEW_GRACE_DAYS * DAY_MS } : {}) })) });
|
|
226
244
|
const key = await crypto.subtle.importKey('jwk', link.sign_private_key, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
|
|
227
245
|
const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, new TextEncoder().encode(body));
|
|
228
246
|
return { body, signature: b64url(new Uint8Array(signature)) };
|
|
229
247
|
}
|
|
230
248
|
|
|
231
|
-
/** List paired devices with their
|
|
249
|
+
/** List paired devices with their passkey counts and unlock times, newest first, and the recent audit entries. */
|
|
232
250
|
export function list_devices(home, current = null) {
|
|
233
251
|
const { devices } = read_registry(home);
|
|
234
252
|
return { audit: recent_audit(home), devices: Object.entries(devices).map(([id, device]) => ({ id, label: device.label, temporary: device.temporary, created_at: device.created_at,
|
|
@@ -238,7 +256,7 @@ export function list_devices(home, current = null) {
|
|
|
238
256
|
}
|
|
239
257
|
|
|
240
258
|
/**
|
|
241
|
-
* Remove a device with its passkeys
|
|
259
|
+
* Remove a device with its passkeys; a device may also remove itself. Returns the removed passkey ids so the
|
|
242
260
|
* page can remove the same passkeys from the website's login list.
|
|
243
261
|
*/
|
|
244
262
|
export function revoke_device(home, device_id) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Shared launch configuration for an existing AI conversation and the local website. */
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
4
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync } from 'node:fs';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { dirname, isAbsolute, join, posix, resolve, win32 } from 'node:path';
|
|
7
7
|
import { DatabaseSync } from 'node:sqlite';
|
|
@@ -10,18 +10,18 @@ import { pathToFileURL } from 'node:url';
|
|
|
10
10
|
import { parseArgs } from 'node:util';
|
|
11
11
|
|
|
12
12
|
import { atomic_json } from './files.mjs';
|
|
13
|
-
import { read_project_files } from './project_files.mjs';
|
|
13
|
+
import { outside_directory, read_only_enforced, read_project_files, room_directories, search_project_roots } from './project_files.mjs';
|
|
14
14
|
import { guard_remote_request, list_remote_directories } from './remote_policy.mjs';
|
|
15
15
|
import { list_devices, machine_fingerprint, revoke_device, start_pairing } from './device_auth.mjs';
|
|
16
16
|
import { setup_sidecar } from './adapters/antigravity_app.mjs';
|
|
17
|
-
import { adapter_of, adoption_adapter, BRANCHABLE_ADAPTERS, branch_session, session_settings, software_defaults, software_effort, software_models } from './adapters/index.mjs';
|
|
17
|
+
import { adapter_of, adoption_adapter, BRANCHABLE_ADAPTERS, branch_session, execution_details, session_settings, software_defaults, software_effort, software_models } from './adapters/index.mjs';
|
|
18
18
|
import { ADAPTER_IDS, ADAPTER_PROFILES, adapter_profile, DEFAULT_API_MAX_REQUESTS, MAX_API_MAX_REQUESTS, platform_adapters, same_session_family } from './adapters/profiles.mjs';
|
|
19
19
|
import { API_PROVIDERS, api_check, api_models, save_api_key, saved_api_keys, validate_api_profile } from './discussion_api.mjs';
|
|
20
20
|
import { DEFAULT_SCHEDULER_MODE, SCHEDULER_MODES, SCHEDULER_PROVIDERS, scheduler_check } from './scheduler.mjs';
|
|
21
21
|
import { save_tts_settings, tts_settings, tts_speak } from './tts.mjs';
|
|
22
22
|
import { stt_part } from './stt.mjs';
|
|
23
23
|
import { access_levels, BUILTIN_EXPERT_PRESETS, MAX_EXPERT_PROMPT, PROJECT_ACCESS } from './expert_prompts.mjs';
|
|
24
|
-
import { acquire_lock, api, DiscussionStore, service_drafts, validate_config } from './discussion_runner.mjs';
|
|
24
|
+
import { acquire_lock, api, check_extra_directories, DiscussionStore, service_drafts, validate_config } from './discussion_runner.mjs';
|
|
25
25
|
import { FreeRoomRunner, free_task_files } from './free_runner.mjs';
|
|
26
26
|
import { bind_room_workflow, delete_workflow, get_workflow, list_workflows, load_room_workflow, parse_workflow_draft, preview_workflow, save_workflow, workflow_directory, WORKFLOW_ID_PATTERN } from './workflows.mjs';
|
|
27
27
|
import { assert_shared_home, CONNECTION_FIELDS, configuration_snapshot, read_registry, registered_identity, register_room, service_home, service_pid, start_service } from './discussion_service.mjs';
|
|
@@ -59,6 +59,7 @@ const HELP = `用法:codiscuss launch settings [--file FILE]
|
|
|
59
59
|
codiscuss launch room-config --file FILE
|
|
60
60
|
codiscuss launch room-models --file FILE
|
|
61
61
|
codiscuss launch room-files --file FILE
|
|
62
|
+
codiscuss launch execution-details --file FILE
|
|
62
63
|
codiscuss launch models --file FILE
|
|
63
64
|
codiscuss launch api-key --file FILE
|
|
64
65
|
codiscuss launch api-check --file FILE
|
|
@@ -91,6 +92,7 @@ start 使用启动 JSON 创建或接入房间并启动;--dry-run 只校验和
|
|
|
91
92
|
room-config 修正已暂停房间的软件路径或切换 Codex App / CLI,不启动讨论;须提供 site、room_id、token。
|
|
92
93
|
room-models 只读指定 site、room_id 的本机登记模型与思考强度,不启动讨论;quick 为 true 时不查询 agy 的默认模型(较慢),该成员标 defaults_pending。
|
|
93
94
|
room-files 只读房间已保存的项目目录,须提供 site、room_id、token,可选相对 path 和目录分页 offset;文件返回最多前 128 KiB 文本;给 query 时改为按名称搜索整个项目(含 / 时按路径),最多返回 50 项。
|
|
95
|
+
execution-details 只读房间某次执行的过程细节(中间说明、命令与输出、搜索、改动、工具调用),由本机日志还原,须提供 site、room_id、token、execution_id;细节只留在本机,不写入网站。
|
|
94
96
|
models 按 adapter、executable 或 API profile 读取模型目录,不启动讨论或生成回答。
|
|
95
97
|
api-key 将 base_url、api_key 保存于本机私有目录,只返回 api_key_ref。
|
|
96
98
|
api-check 用 API profile 向模型发送一次极短请求,核对地址、密钥、协议与模型是否可用。
|
|
@@ -100,7 +102,7 @@ tts 用朗读设置把 text(不超过 300 字)合成为 MP3,返回 base64
|
|
|
100
102
|
stt 按序接收录音片段(upload_id、index、base64 PCM 16 kHz 单声道、final);最后一段时识别整段录音并返回 text。
|
|
101
103
|
pick-directory 在本机弹出系统目录选择窗口,返回所选路径;取消时不返回路径。
|
|
102
104
|
browse-directory 列出常用项目及其中的子文件夹(只含文件夹名,分页),供其他设备选择目录;path 为空时列出常用项目。
|
|
103
|
-
device-pairing 在本机生成 12 位设备配对码(2 分钟有效,可选 temporary 临时设备、site 指定已绑定的网站),扫码链接带本机密钥指纹;只能在这台电脑上执行,新设备只能在电脑前添加。
|
|
105
|
+
device-pairing 在本机生成 12 位设备配对码(2 分钟有效,可选 temporary 临时设备、without_passkey 允许这次配对不用通行密钥、site 指定已绑定的网站),扫码链接带本机密钥指纹;只能在这台电脑上执行,新设备只能在电脑前添加。
|
|
104
106
|
devices 列出已配对设备;提供 revoke(设备 id)撤销设备。
|
|
105
107
|
projects 读取本机已配置的项目(名称与绝对路径);提供 JSON(projects 列表)时整体替换保存。
|
|
106
108
|
presets 读取本机的专家预设(含未删除的内置预设)与各平台默认描述;提供 JSON(presets 列表、description_templates 和/或 hidden_builtins 已删除的内置预设名)时替换对应部分。
|
|
@@ -342,11 +344,15 @@ export async function room_models(home, raw, { read_defaults = profile => cached
|
|
|
342
344
|
...(actual.model ? { actual_model: actual.model } : {}), ...(actual.effort ? { actual_effort: actual.effort } : {}),
|
|
343
345
|
project_access: !facts?.capabilities.project ? 'read' : profile.project_access || config.project_access || 'read',
|
|
344
346
|
// A member bound to its own conversation may keep that conversation's project, so this tool cannot set its access.
|
|
345
|
-
own_project: Boolean(facts?.own_project_when_bound && profile.session_id)
|
|
347
|
+
own_project: Boolean(facts?.own_project_when_bound && profile.session_id),
|
|
348
|
+
// A member working outside every project directory of the room only reads them; the page warns and names its directory.
|
|
349
|
+
...(facts?.capabilities.project && outside_directory(config, profile) ? { outside_directory: outside_directory(config, profile),
|
|
350
|
+
outside_enforced: read_only_enforced({ ...profile, project_access: profile.project_access || config.project_access || 'read' }) } : {}) }];
|
|
346
351
|
})));
|
|
347
352
|
// Smart scheduling is a local choice; only which model decides is shown, never its key.
|
|
348
353
|
const scheduler = config.scheduler ? { provider: config.scheduler.provider, model: config.scheduler.model, mode: config.scheduler.mode || DEFAULT_SCHEDULER_MODE } : null;
|
|
349
354
|
return { room_id: input.room_id, registered: paths.length > 0, participants, scheduler, project_directory: config.cwd || '',
|
|
355
|
+
extra_directories: config.extra_directories || [],
|
|
350
356
|
workflow: room_workflow_status(config, home), host_workflows: Boolean(config.host_workflows),
|
|
351
357
|
api_max_requests: config.api_max_requests ?? DEFAULT_API_MAX_REQUESTS };
|
|
352
358
|
}
|
|
@@ -359,10 +365,17 @@ function room_workflow_status(config, home) {
|
|
|
359
365
|
available: !loaded.error, members: config.workflow.members || {} };
|
|
360
366
|
}
|
|
361
367
|
|
|
362
|
-
/**
|
|
368
|
+
/**
|
|
369
|
+
* Read the owner's saved room projects, never a directory supplied by the request: `root` picks one of the room's
|
|
370
|
+
* directories by index (0, the main one, by default), and a search covers all of them.
|
|
371
|
+
*/
|
|
363
372
|
export async function room_files(home, raw, { trust = false } = {}) {
|
|
364
373
|
const { input, config } = await member_room_context(home, raw, { trust });
|
|
365
|
-
|
|
374
|
+
const directories = room_directories(config);
|
|
375
|
+
if (input.query) return { room_id: input.room_id, ...search_project_roots(directories, input.query) };
|
|
376
|
+
const root = input.root ?? 0;
|
|
377
|
+
if (!Number.isSafeInteger(root) || root < 0 || (directories.length && root >= directories.length)) throw new Error('项目目录编号无效,请刷新后重试');
|
|
378
|
+
return { room_id: input.room_id, root, ...read_project_files(directories[root], input.path, input.offset) };
|
|
366
379
|
}
|
|
367
380
|
|
|
368
381
|
/**
|
|
@@ -375,6 +388,33 @@ export async function remote_room_files(home, input) {
|
|
|
375
388
|
return room_files(home, input, { trust: true });
|
|
376
389
|
}
|
|
377
390
|
|
|
391
|
+
// The newest part of a long-running execution's log is what its detail view needs; older lines are dropped.
|
|
392
|
+
const DETAIL_LOG_TAIL_BYTES = 4 * 1024 * 1024;
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* What a member did in one execution, rebuilt from the raw event log its process wrote in the room's runtime directory
|
|
396
|
+
* (`<adapter>-free-<execution>.log`). The log stays on this computer: the page reads it here or through the relay.
|
|
397
|
+
* `available: false` when the execution kept no such log (an App or manual member, or an API member).
|
|
398
|
+
*/
|
|
399
|
+
export async function execution_details_command(home, raw, { trust = false } = {}) {
|
|
400
|
+
const { input, config } = await member_room_context(home, raw, { trust });
|
|
401
|
+
const execution_id = Number(input.execution_id);
|
|
402
|
+
if (!Number.isSafeInteger(execution_id) || execution_id <= 0) throw new Error('执行编号无效');
|
|
403
|
+
const suffix = `-free-${execution_id}.log`;
|
|
404
|
+
const name = existsSync(config.directory) ? readdirSync(config.directory).find(file => file.endsWith(suffix)) : undefined;
|
|
405
|
+
const parsed = name ? execution_details(name.slice(0, -suffix.length), read_log_tail(join(config.directory, name))) : null;
|
|
406
|
+
return { room_id: input.room_id, execution_id, available: Boolean(parsed), ...parsed };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The last bytes of a log as text, starting at a whole line. */
|
|
410
|
+
function read_log_tail(path) {
|
|
411
|
+
const size = statSync(path).size, length = Math.min(size, DETAIL_LOG_TAIL_BYTES);
|
|
412
|
+
const buffer = Buffer.alloc(length), descriptor = openSync(path, 'r');
|
|
413
|
+
try { readSync(descriptor, buffer, 0, length, size - length); } finally { closeSync(descriptor); }
|
|
414
|
+
const text = buffer.toString('utf8');
|
|
415
|
+
return length < size ? text.slice(text.indexOf('\n') + 1) : text;
|
|
416
|
+
}
|
|
417
|
+
|
|
378
418
|
const CREATE_PREFERENCE_RANGES = { max_rounds: [0, 100], max_minutes: [0, 1440], goal_max_continues: [0, 100], goal_max_minutes: [0, 1440], chain_rounds: [0, 100],
|
|
379
419
|
api_max_requests: [0, MAX_API_MAX_REQUESTS] };
|
|
380
420
|
const CREATE_PREFERENCE_FLAGS = ['named_followup', 'host_workflows', 'advanced_open'];
|
|
@@ -578,6 +618,9 @@ function launch_input(input) {
|
|
|
578
618
|
if (!['conversation', 'web'].includes(value.entry)) throw new Error('entry 必须为 conversation 或 web');
|
|
579
619
|
if (value.entry === 'conversation' && (typeof value.current_session_id !== 'string' || !value.current_session_id.trim())) throw new Error('对话入口须提供当前实际会话的 current_session_id');
|
|
580
620
|
if (value.cwd && (typeof value.cwd !== 'string' || !isAbsolute(value.cwd))) throw new Error('cwd 必须为完整绝对路径');
|
|
621
|
+
// A new room's extra project directories; an existing room changes them through room-settings.
|
|
622
|
+
if (!value.room_id && value.extra_directories?.length) check_extra_directories(value.cwd, value.extra_directories);
|
|
623
|
+
else if (!value.room_id) delete value.extra_directories;
|
|
581
624
|
if (value.project_access && !PROJECT_ACCESS.includes(value.project_access)) throw new Error('project_access 只能为 read、write、auto 或 full');
|
|
582
625
|
if (value.room_id && !/^[A-Za-z0-9_-]+$/.test(value.room_id)) throw new Error('房间编号无效');
|
|
583
626
|
if (!value.room_id && !value.room?.submission_id) throw new Error('新房间须提供 room.submission_id;重试沿用原编号');
|
|
@@ -622,7 +665,7 @@ function launch_config(home, input, room, directory, options) {
|
|
|
622
665
|
const members = launch_members(input, Object.keys(participants), options);
|
|
623
666
|
return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, mode: 'free', participants, members, api_home: home,
|
|
624
667
|
scheduler: input.scheduler || undefined,
|
|
625
|
-
cwd: input.cwd, discussion_project: input.discussion_project, project_access: input.project_access || undefined,
|
|
668
|
+
cwd: input.cwd, extra_directories: input.extra_directories, discussion_project: input.discussion_project, project_access: input.project_access || undefined,
|
|
626
669
|
data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) }, directory);
|
|
627
670
|
}
|
|
628
671
|
|
|
@@ -640,7 +683,7 @@ function free_launch_config(home, input, room, directory, options) {
|
|
|
640
683
|
if (workflow) get_workflow(home, workflow.id);
|
|
641
684
|
if (workflow && Object.values(workflow.members).some(name => !names.includes(name))) throw new Error('工作流成员须在本次邀请的成员中');
|
|
642
685
|
return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, mode: 'free', participants, members, api_home: home,
|
|
643
|
-
scheduler: input.scheduler || undefined, cwd: input.cwd, project_access: input.project_access || undefined, workflow,
|
|
686
|
+
scheduler: input.scheduler || undefined, cwd: input.cwd, extra_directories: input.extra_directories, project_access: input.project_access || undefined, workflow,
|
|
644
687
|
host_workflows: input.host_workflows === true || undefined, api_max_requests: input.api_max_requests,
|
|
645
688
|
data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) }, directory);
|
|
646
689
|
}
|
|
@@ -704,7 +747,9 @@ function same_directory(left, right) {
|
|
|
704
747
|
/**
|
|
705
748
|
* Prove an invited existing conversation is on this machine before it can hold a turn, so a mistyped ID
|
|
706
749
|
* fails here instead of pausing the discussion later. Claude stores sessions per directory and resumes only
|
|
707
|
-
* from the original one, so its cwd follows the session
|
|
750
|
+
* from the original one, so its cwd follows the session: a session kept elsewhere than the room's project joins as a
|
|
751
|
+
* member working in its own directory (it only reads the room's directories); only a directory the member's own
|
|
752
|
+
* settings name explicitly must match.
|
|
708
753
|
*/
|
|
709
754
|
export function borrowed_session(name, profile, room_cwd, env = process.env) {
|
|
710
755
|
const id = profile.session_id;
|
|
@@ -718,9 +763,8 @@ export function borrowed_session(name, profile, room_cwd, env = process.env) {
|
|
|
718
763
|
if (adapter.adopt !== 'own_cwd') return profile;
|
|
719
764
|
const cwd = adapter.session.cwd(id, { env });
|
|
720
765
|
if (!cwd) throw new Error(`${name}:${adapter.label} 会话 ${id} 没有记录原工作目录,无法续接`);
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
return { ...profile, cwd: wanted || cwd };
|
|
766
|
+
if (profile.cwd && !same_directory(profile.cwd, cwd)) throw new Error(`${name}:这个 ${adapter.label} 会话属于 ${cwd},只能在原目录续接;请让此 AI 的工作目录与它一致,或留空会话 ID 新建会话`);
|
|
767
|
+
return { ...profile, cwd: profile.cwd || (room_cwd && same_directory(room_cwd, cwd) ? room_cwd : cwd) };
|
|
724
768
|
}
|
|
725
769
|
|
|
726
770
|
// An owner credential the website accepted is trusted this long for read-only file browsing in a long-running process
|
|
@@ -824,19 +868,36 @@ export async function room_member(home, raw, locked = false) {
|
|
|
824
868
|
|
|
825
869
|
|
|
826
870
|
/**
|
|
827
|
-
* Change this room's runner settings: `api_max_requests`, how many model requests one execution of an API member
|
|
828
|
-
* make (0 = no limit)
|
|
871
|
+
* Change this room's runner settings, any of: `api_max_requests`, how many model requests one execution of an API member
|
|
872
|
+
* may make (0 = no limit); `cwd`, the main project directory, accepted only while the room has none (once set it never
|
|
873
|
+
* changes: sessions and file-change tracking are bound to it); `extra_directories`, the project directories shared
|
|
874
|
+
* besides the main one, replaced as a whole. Members already in a room that gets its main directory keep working where
|
|
875
|
+
* their sessions are (their `cwd` is pinned to '') and reach the room's directories as extra ones; members invited later
|
|
876
|
+
* work in it. Everything is read at each delivery, so it applies from the next execution and keeps every session.
|
|
829
877
|
*/
|
|
830
878
|
export async function room_settings(home, raw) {
|
|
831
879
|
const { input, path } = await member_room_context(home, raw);
|
|
832
|
-
const
|
|
833
|
-
if (
|
|
880
|
+
const { api_max_requests: requests, extra_directories: directories, cwd } = input;
|
|
881
|
+
if (requests === undefined && directories === undefined && cwd === undefined) throw new Error('须提供 api_max_requests、cwd 或 extra_directories');
|
|
882
|
+
if (requests !== undefined && !Number.isInteger(requests)) throw new Error('api_max_requests 须为整数');
|
|
834
883
|
const release = acquire_lock(join(dirname(path), 'configuration-edit'));
|
|
835
884
|
try {
|
|
836
|
-
const
|
|
885
|
+
const config = configuration_snapshot(path).config;
|
|
886
|
+
const next = { ...config, ...(requests !== undefined ? { api_max_requests: requests } : {}) };
|
|
887
|
+
if (cwd !== undefined) {
|
|
888
|
+
if (config.cwd) throw new Error('本房间已有项目目录,建房后不能更改;可添加其他项目目录');
|
|
889
|
+
if (typeof cwd !== 'string' || !isAbsolute(cwd) || !existsSync(cwd) || !statSync(cwd).isDirectory()) throw new Error('项目目录须为本机已有文件夹的完整路径');
|
|
890
|
+
next.cwd = cwd;
|
|
891
|
+
next.participants = Object.fromEntries(Object.entries(config.participants || {}).map(([name, profile]) => [name, { ...profile, cwd: profile.cwd ?? '' }]));
|
|
892
|
+
}
|
|
893
|
+
if (directories !== undefined) {
|
|
894
|
+
check_extra_directories(next.cwd, directories);
|
|
895
|
+
next.extra_directories = directories.length ? directories : undefined;
|
|
896
|
+
}
|
|
837
897
|
validate_config(next, dirname(path));
|
|
838
898
|
atomic_json(path, next);
|
|
839
|
-
return { room_id: input.room_id, api_max_requests:
|
|
899
|
+
return { room_id: input.room_id, ...(requests !== undefined ? { api_max_requests: requests } : {}),
|
|
900
|
+
...(cwd !== undefined ? { project_directory: cwd } : {}), ...(directories !== undefined ? { extra_directories: directories } : {}) };
|
|
840
901
|
} finally { release(); }
|
|
841
902
|
}
|
|
842
903
|
|
|
@@ -1774,6 +1835,7 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
|
|
|
1774
1835
|
else if (positionals[0] === 'stt' && input) result = await stt_part(home, input);
|
|
1775
1836
|
else if (positionals[0] === 'room-models' && input) result = await room_models(home, input);
|
|
1776
1837
|
else if (positionals[0] === 'room-files' && input) result = await room_files(home, input);
|
|
1838
|
+
else if (positionals[0] === 'execution-details' && input) result = await execution_details_command(home, input);
|
|
1777
1839
|
else if (positionals[0] === 'models' && adapter_profile(input?.adapter)?.kind === 'api') result = await api_models(home, input);
|
|
1778
1840
|
else if (positionals[0] === 'models' && input) {
|
|
1779
1841
|
const saved = Object.values(launch_settings(home).participants).find(profile => profile.adapter === input.adapter);
|
|
@@ -1796,7 +1858,7 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
|
|
|
1796
1858
|
const { keyed_links } = await import('./remote_launch.mjs');
|
|
1797
1859
|
const [site, link] = Object.entries(keyed_links(home).sites).find(([origin]) => !input?.site || origin === input.site) || [];
|
|
1798
1860
|
if (!link) throw new Error('这台电脑还没有绑定线上网站,请先运行 codiscuss local connect 网站地址');
|
|
1799
|
-
result = start_pairing(home, { site, fingerprint: machine_fingerprint(link), temporary: input?.temporary === true });
|
|
1861
|
+
result = start_pairing(home, { site, fingerprint: machine_fingerprint(link), temporary: input?.temporary === true, without_passkey: input?.without_passkey === true });
|
|
1800
1862
|
}
|
|
1801
1863
|
else if (positionals[0] === 'devices' && !values.remote) {
|
|
1802
1864
|
result = input?.revoke ? revoke_device(home, String(input.revoke)) : list_devices(home);
|
|
@@ -91,6 +91,8 @@ async function bind_machine(home, origin, setup_key) {
|
|
|
91
91
|
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...link, setup_key: key }) });
|
|
92
92
|
const result = await response.json().catch(() => ({}));
|
|
93
93
|
if (!response.ok) throw new Error(result.error || `绑定失败:HTTP ${response.status}`);
|
|
94
|
+
// From now on this website reaches the local one only as the relay's bridge, never with a browser connection credential.
|
|
95
|
+
(await import('./relay_channel.mjs')).bridge_token(home, origin);
|
|
94
96
|
await sync_remote_launches(home);
|
|
95
97
|
}
|
|
96
98
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
5
|
-
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
6
6
|
import { DatabaseSync } from 'node:sqlite';
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
8
|
import { parseArgs } from 'node:util';
|
|
@@ -15,6 +15,8 @@ import { validate_scheduler } from './scheduler.mjs';
|
|
|
15
15
|
import { bind_room_workflow } from './workflows.mjs';
|
|
16
16
|
|
|
17
17
|
const POLL_MS = 2000;
|
|
18
|
+
// A room's members work in its main project directory and at most this many extra ones.
|
|
19
|
+
export const MAX_EXTRA_DIRECTORIES = 8;
|
|
18
20
|
let rg_available;
|
|
19
21
|
/**
|
|
20
22
|
* Windows PowerShell mis-decodes UTF-8 files, so point the AI at rg when its commands can run it; free rooms and
|
|
@@ -76,6 +78,7 @@ export function validate_config(input, base_directory) {
|
|
|
76
78
|
if (Object.keys(config.participants).some(name => typeof config.members?.[name]?.token !== 'string' || config.members[name].token.length < 32)) {
|
|
77
79
|
throw new Error('房间配置须为每位成员保存成员凭证;旧版配置请重新接入房间');
|
|
78
80
|
}
|
|
81
|
+
if (config.extra_directories !== undefined) check_extra_directories(config.cwd, config.extra_directories, { exists: false });
|
|
79
82
|
// A bound workflow only names a folder and which member plays which card; its content is read at each execution.
|
|
80
83
|
if (config.workflow) config.workflow = bind_room_workflow({}, config.workflow).workflow;
|
|
81
84
|
// How many model requests an API member's execution may make; absent means the adapter's default.
|
|
@@ -86,6 +89,36 @@ export function validate_config(input, base_directory) {
|
|
|
86
89
|
return { ...config, mode: 'free', site: url.origin, project: resolve(config.project), directory: resolve(config.data_directory || join(base_directory, `discussion-${config.room}`)) };
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
/** Whether one directory is the other or lies inside it; Windows paths compare without case. */
|
|
93
|
+
export function overlapping_directories(left, right) {
|
|
94
|
+
const [a, b] = [left, right].map(path => process.platform === 'win32' ? resolve(path).toLowerCase() : resolve(path));
|
|
95
|
+
const inside = (outer, inner) => { const rest = relative(outer, inner); return !rest.startsWith('..') && !isAbsolute(rest); };
|
|
96
|
+
return inside(a, b) || inside(b, a);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check a room's extra project directories: existing folders given as absolute paths, added to a main directory, and
|
|
101
|
+
* none inside another (a folder inside one already shared adds nothing and would be watched twice).
|
|
102
|
+
* @param {string} cwd - the room's main project directory.
|
|
103
|
+
* @param {unknown} directories - the extra directories as given.
|
|
104
|
+
* @param {{exists?: boolean}} options - `exists: false` skips the existence check, so a saved room whose folder was
|
|
105
|
+
* later moved still loads (its members then work without it).
|
|
106
|
+
* @returns {string[]} the directories, unchanged.
|
|
107
|
+
*/
|
|
108
|
+
export function check_extra_directories(cwd, directories, { exists = true } = {}) {
|
|
109
|
+
if (!Array.isArray(directories) || directories.length > MAX_EXTRA_DIRECTORIES) throw new Error(`extra_directories 须为最多 ${MAX_EXTRA_DIRECTORIES} 个目录的列表`);
|
|
110
|
+
if (directories.length && !cwd) throw new Error('添加其他项目目录前须先指定房间的项目目录');
|
|
111
|
+
const seen = cwd ? [cwd] : [];
|
|
112
|
+
for (const directory of directories) {
|
|
113
|
+
if (typeof directory !== 'string' || !isAbsolute(directory)) throw new Error('项目目录须为完整绝对路径');
|
|
114
|
+
if (exists && (!existsSync(directory) || !statSync(directory).isDirectory())) throw new Error(`项目目录 ${directory} 在本机不存在或不是文件夹`);
|
|
115
|
+
const overlap = seen.find(other => overlapping_directories(other, directory));
|
|
116
|
+
if (overlap) throw new Error(`项目目录 ${directory} 与 ${overlap} 重复或互相包含`);
|
|
117
|
+
seen.push(directory);
|
|
118
|
+
}
|
|
119
|
+
return directories;
|
|
120
|
+
}
|
|
121
|
+
|
|
89
122
|
/** Send exactly one bounded website request; never follow redirects with a capability. */
|
|
90
123
|
export async function api(config, tail, body, token = config.token) {
|
|
91
124
|
const response = await fetch(`${config.site}/api/rooms/${config.room}${tail}`, {
|
|
@@ -79,26 +79,33 @@ export function access_label(level) { return level === 'own' ? '自行决定' :
|
|
|
79
79
|
export const TASK_SUBCOMMANDS = ['room', 'message', 'turn', 'state', 'goal', 'mode', 'review', 'sequence', 'execution', 'health', 'schema', 'launch workflow-adopt', 'launch workflow-join'];
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
|
-
* Whether a member may run commands beyond the codiscuss subcommands: every level but 只读, and 可写 only with a project
|
|
83
|
-
*
|
|
82
|
+
* Whether a member may run commands beyond the codiscuss subcommands: every level but 只读, and 可写 only with a project
|
|
83
|
+
* (its own, or one of the room's directories it may write, `extra_directories`). A taken-over manual member (`own`)
|
|
84
|
+
* keeps its own session's permissions, so the room's limits do not apply.
|
|
84
85
|
*/
|
|
85
|
-
export function runs_commands(config) {
|
|
86
|
+
export function runs_commands(config, extra_directories = []) {
|
|
86
87
|
const access = config.project_access;
|
|
87
|
-
return ['own', 'auto', 'full'].includes(access) || (access === 'write'
|
|
88
|
+
return ['own', 'auto', 'full'].includes(access) || (access === 'write'
|
|
89
|
+
&& Boolean(config.cwd || config.workdir || extra_directories.some(item => item.access === 'write')));
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
+
/**
|
|
93
|
+
* The task text's rule for project files and commands; `purpose` says what the member works for (按请求 / 按议题需要).
|
|
94
|
+
* A member without its own project may still have the room's directories in `extra_directories`.
|
|
95
|
+
*/
|
|
96
|
+
export function project_rule(profile, purpose, extra_directories = []) {
|
|
92
97
|
if (profile.project_access === 'own') return '项目文件与命令权限沿用你自己会话的软件设置。';
|
|
93
98
|
if (profile.project_access === 'auto') return `由软件自动审批:可${purpose}修改项目文件并运行所需命令;被沙箱或审批拦下时按软件方式申请放行(Codex 申请提权),仍被拒绝就在回答中说明。`;
|
|
94
99
|
if (profile.project_access === 'full') return `已跳过软件审批与沙箱,可${purpose}修改文件并运行所需命令。`;
|
|
100
|
+
if (!profile.cwd && extra_directories.length) return runs_commands(profile, extra_directories)
|
|
101
|
+
? `用户允许${purpose}修改项目文件并运行所需命令。` : '项目文件只读,不修改。';
|
|
95
102
|
if (!profile.cwd) return '未指定项目目录。';
|
|
96
103
|
return profile.project_access === 'write' ? `用户允许${purpose}修改 cwd 中的项目文件并运行所需命令。` : 'cwd 中的项目文件只读,不修改。';
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
/** The fixed rules one software needs in every task, from its adapter profile; no member prompt can drop them. */
|
|
100
|
-
export function software_rules(config, cli) {
|
|
101
|
-
return adapter_profile(config.adapter)?.task_rules?.(cli, { runs_commands: runs_commands(config) }) || '';
|
|
107
|
+
export function software_rules(config, cli, extra_directories = []) {
|
|
108
|
+
return adapter_profile(config.adapter)?.task_rules?.(cli, { runs_commands: runs_commands(config, extra_directories) }) || '';
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
/** Windows PowerShell mis-decodes UTF-8 files; the runner adds this where the member's commands can run rg. */
|
|
@@ -112,11 +119,14 @@ export const READ_RULE = '读含中文的文件用 rg --encoding utf-8 . 文件
|
|
|
112
119
|
* otherwise where the task file is, plus how to read files at all; the task file's fields carry this execution's values
|
|
113
120
|
* (messages, read point, execution, role, credentials), the skills it lists say how to use them, and `instructions`
|
|
114
121
|
* holds only the limits no skill can know. `access_file` is set where the credential is not bound to the process.
|
|
122
|
+
* `extra_directories` are the room's other project directories this member works with, each read-only or writable.
|
|
115
123
|
*/
|
|
116
|
-
export function free_task_text({ name, discussion, discussion_request, profile, cli, messages, read_after, task_path, access_file = '', read_rule = ''
|
|
124
|
+
export function free_task_text({ name, discussion, discussion_request, profile, cli, messages, read_after, task_path, access_file = '', read_rule = '',
|
|
125
|
+
extra_directories = [] }) {
|
|
117
126
|
const scope = discussion ? `讨论进行中,用户直接向你提问(${messages}):只回答这个提问,不改变讨论安排和进度。` : '';
|
|
118
|
-
const commands = runs_commands(profile) ? '' : `命令只用 subcommands 列出的 ${cli} 子命令。`;
|
|
119
|
-
const
|
|
127
|
+
const commands = runs_commands(profile, extra_directories) ? '' : `命令只用 subcommands 列出的 ${cli} 子命令。`;
|
|
128
|
+
const extras = extra_directories.length ? 'extra_directories 是本房间的其他项目目录,access 为 read 的只读、不修改。' : '';
|
|
129
|
+
const instructions = `${software_rules(profile, cli, extra_directories)}${scope}${project_rule(profile, '按请求', extra_directories)}${extras}${commands}草稿写在 draft_directory。`;
|
|
120
130
|
if (discussion_request) {
|
|
121
131
|
const role = discussion_request.purpose === 'discuss_host' ? '主持任务' : '发言请求';
|
|
122
132
|
const prompt = `你(${name})收到了讨论中的${role}。先读任务文件 ${task_path},按其中列出的技能处理本次请求并保存结果。${read_rule}`;
|