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.
@@ -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
 
@@ -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
- /** Start a pairing at this computer's own screen: a 12-character code, valid two minutes, and the link a camera can open. */
140
- export function start_pairing(home, { site, fingerprint = '', temporary = false, by = 'computer' }) {
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
- /** Record a paired device with its passkey: thirty days, or one day for a temporary one. */
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 with the passkey, so the device starts unlocked.
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
- if (device.expires_at <= Date.now() && !EXPIRED_ACTIONS.has(request.action)) {
210
- throw Object.assign(new Error(device.temporary ? '临时设备授权已到期,请重新配对' : '设备授权已到期,请用通行密钥续期'), { need: device.temporary ? 'pairing' : 'renew' });
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
- if (!(device.unlocked_until > Date.now()) && !LOCKED_ACTIONS.has(request.action)) {
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 expiry, nothing else. The website
220
- * checks requests against it and cannot add a device of its own.
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 passkeys and write grants, newest first, and the recent audit entries. */
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 and grants; a device may also remove itself. Returns the removed passkey ids so the
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
- /** Read the owner's saved room project, never a directory supplied by the request. */
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
- return { room_id: input.room_id, ...read_project_files(config.cwd, input.path, input.offset, input.query) };
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; a different room project is refused.
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
- const wanted = profile.cwd || room_cwd;
722
- if (wanted && !same_directory(wanted, cwd)) throw new Error(`${name}:这个 Claude 会话属于 ${cwd},只能在原目录续接;请让项目目录与它一致,或留空会话 ID 新建会话`);
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 may
828
- * make (0 = no limit). Read at each delivery, so it applies from the next execution.
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 value = input.api_max_requests;
833
- if (!Number.isInteger(value)) throw new Error('api_max_requests 须为整数');
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 next = { ...configuration_snapshot(path).config, api_max_requests: value };
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: value };
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
- * A taken-over manual member (`own`) keeps its own session's permissions, so the room's limits do not apply.
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' && Boolean(config.cwd || config.workdir));
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
- /** The task text's rule for project files and commands; `purpose` says what the member works for (按请求 / 按议题需要). */
91
- export function project_rule(profile, purpose) {
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 instructions = `${software_rules(profile, cli)}${scope}${project_rule(profile, '按请求')}${commands}草稿写在 draft_directory。`;
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}`;