ai_co_discuss 0.3.71 → 0.3.73
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 +19 -8
- 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/discussion_service.mjs +6 -3
- 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
package/runner/free_runner.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/** Free-room delivery: one live execution per member, carrying all of that member's open requests. */
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
-
import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
5
5
|
import { basename, join, resolve } from 'node:path';
|
|
6
6
|
import { adapter_of, BRANCHABLE_ADAPTERS, branch_session, capabilities_of, cleanup_room, deliver_task, delivery_receipt, session_exists, stored_delivery } from './adapters/index.mjs';
|
|
7
|
-
import { api, read_rule } from './discussion_runner.mjs';
|
|
7
|
+
import { api, overlapping_directories, read_rule } from './discussion_runner.mjs';
|
|
8
8
|
import { SegmentLog } from './execution_progress.mjs';
|
|
9
9
|
import { free_task_text, TASK_SUBCOMMANDS, with_expert_prompt } from './expert_prompts.mjs';
|
|
10
10
|
import { atomic_json } from './files.mjs';
|
|
11
|
+
import { outside_directory, room_directories } from './project_files.mjs';
|
|
11
12
|
import { SessionDriver } from './session_driver.mjs';
|
|
12
13
|
import { effective_profile, load_room_workflow, workflow_in_use, workflow_task_field } from './workflows.mjs';
|
|
13
14
|
import { DEFAULT_SCHEDULER_MODE, GOAL_PROMPT_VERSION, decide_goal_wake, decide_sequence_checkpoint, REVIEW_CONFIDENCE, REVIEW_PROMPT_VERSION, SEQUENCE_CONFIDENCE, SEQUENCE_PROMPT_VERSION, ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, decide_review_completion, route_free_message, route_prompt_version, scheduler_thresholds, some_members } from './scheduler.mjs';
|
|
@@ -94,6 +95,25 @@ export function edited_changes(changes, claimed, platform = process.platform) {
|
|
|
94
95
|
return changes.filter(path => named.some(item => item.endsWith(normal(path))));
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The room's project directories a member works with besides its own cwd, each `{path, access}`: a member working in
|
|
100
|
+
* the room's project gets the others at its own project access (read or write); a member working outside them gets
|
|
101
|
+
* them all read-only. Software without project files gets none, and a folder gone from this computer is left out.
|
|
102
|
+
*/
|
|
103
|
+
export function member_extra_directories(config, profile) {
|
|
104
|
+
if (!profile || !capabilities_of(profile.adapter).project) return [];
|
|
105
|
+
// '' marks a member that joined before the room had a project: it reaches every room directory at its own access.
|
|
106
|
+
const cwd = profile.cwd ?? config.cwd;
|
|
107
|
+
const access = outside_directory(config, profile) || profile.project_access === 'read' ? 'read' : 'write';
|
|
108
|
+
return room_directories(config).filter(path => !(cwd && overlapping_directories(path, cwd)) && existsSync(path))
|
|
109
|
+
.map(path => ({ path, access }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A changed path in an extra directory, reported in full so it cannot be mistaken for one in the main directory. */
|
|
113
|
+
function extra_change(directory, path) {
|
|
114
|
+
return `${cwd_key(directory)}/${path}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
97
117
|
/** Write a non-secret task and a member-only credential file for one execution. */
|
|
98
118
|
export function free_task_files(config, member, execution, binding, { session = null, suggestions = [], reviews = [], stopped = [], process_access = true,
|
|
99
119
|
workflow = null, room_members = [], goal = null } = {}) {
|
|
@@ -109,6 +129,7 @@ export function free_task_files(config, member, execution, binding, { session =
|
|
|
109
129
|
atomic_json(access_path, { site: config.site, room: config.room, member: member.name, token: config.members[member.name].token,
|
|
110
130
|
session_id: binding.session_id || '', execution_id: execution.id });
|
|
111
131
|
const adapter = adapter_of(profile.adapter);
|
|
132
|
+
const extra_directories = member_extra_directories(config, profile);
|
|
112
133
|
const cli = process.platform === 'win32' ? adapter?.windows_command || 'codiscuss.cmd' : 'codiscuss';
|
|
113
134
|
const inbox = Boolean(adapter?.into_window?.(profile, binding));
|
|
114
135
|
const bound_access = process_access && !inbox && Boolean(adapter?.capabilities.access_env);
|
|
@@ -119,7 +140,7 @@ export function free_task_files(config, member, execution, binding, { session =
|
|
|
119
140
|
const { instructions, prompt } = free_task_text({ name: member.label || member.name, role: member.role, discussion: direct_question,
|
|
120
141
|
discussion_request, profile, cli,
|
|
121
142
|
room: config.room, messages, read_after: execution.read_until, execution_id: execution.id, task_path,
|
|
122
|
-
access_file: bound_access ? '' : access_path, read_rule: read_rule(profile.adapter), inbox });
|
|
143
|
+
access_file: bound_access ? '' : access_path, read_rule: read_rule(profile.adapter), inbox, extra_directories });
|
|
123
144
|
const skills = discussion_request || direct_question
|
|
124
145
|
? ['discuss-participant/SKILL.md', 'discuss-participant/references/discussion.md',
|
|
125
146
|
...member.role === 'host' ? ['discuss-host/SKILL.md', 'discuss-host/references/discussion.md'] : [],
|
|
@@ -146,13 +167,14 @@ export function free_task_files(config, member, execution, binding, { session =
|
|
|
146
167
|
...(stopped.length ? { stopped_executions: stopped } : {}),
|
|
147
168
|
role: member.role || 'member',
|
|
148
169
|
read_after: execution.read_until, cwd: profile.cwd || null, project_access: profile.project_access, draft_directory,
|
|
170
|
+
...(extra_directories.length ? { extra_directories } : {}),
|
|
149
171
|
skills: skills.map(file => join(config.project, '.agents/skills', file)),
|
|
150
172
|
// The workflow's SKILL.md is not a skill read on every task: the host opens it when a request fits its description.
|
|
151
173
|
...(workflow_field ? { workflow: { ...workflow_field, skill_path: host_workflow.skill_path } } : {}),
|
|
152
174
|
...(member.role === 'host' && config.host_workflows ? { host_workflows: true } : {}),
|
|
153
175
|
cli, subcommands: TASK_SUBCOMMANDS,
|
|
154
176
|
...(bound_access ? { access_env: 'DISCUSS_ACCESS_FILE' } : { access_file: access_path }), instructions });
|
|
155
|
-
return { id, participant: member.name, directory: config.directory, draft_directory, ...(goal_field ? { goal: goal_field } : {}),
|
|
177
|
+
return { id, participant: member.name, directory: config.directory, draft_directory, extra_directories, ...(goal_field ? { goal: goal_field } : {}),
|
|
156
178
|
skill_directories: [skills_directory, ...(host_workflow ? [host_workflow.directory] : [])], access_path, prompt: with_expert_prompt(prompt, profile), inbox,
|
|
157
179
|
profile, ...(workflow_field ? { workflow: { ...workflow_field, skill: host_workflow.skill } } : {}) };
|
|
158
180
|
}
|
|
@@ -201,6 +223,9 @@ export class FreeRoomRunner {
|
|
|
201
223
|
// Routed requests whose decision is still pending: their host waits, so one pick replaces the host's start.
|
|
202
224
|
this.routing = new Set();
|
|
203
225
|
this.children = new Set();
|
|
226
|
+
// Per live execution, how to pass an interjection to its running software; and interjections already handed over.
|
|
227
|
+
this.steers = new Map();
|
|
228
|
+
this.interjecting = new Map();
|
|
204
229
|
this.members = new Map();
|
|
205
230
|
// An empty set proves every tracked handle for this execution closed; a missing set proves nothing after restart.
|
|
206
231
|
this.execution_processes = new Map();
|
|
@@ -294,6 +319,7 @@ export class FreeRoomRunner {
|
|
|
294
319
|
if (LIVE.includes(row.status) && !present.has(row.member)) { this.stop_member(row.member); this.save(row.member, { status: 'idle' }); }
|
|
295
320
|
}
|
|
296
321
|
await this.report_finished(active);
|
|
322
|
+
await this.deliver_interjections(inbox.executions);
|
|
297
323
|
const pending_stops = await this.report_stops(active);
|
|
298
324
|
// Start in the room's speaking order: execution ids follow it, and the website lays out working cards by them.
|
|
299
325
|
const rank = (room.speaking_order?.items || []).map(item => item.name);
|
|
@@ -677,7 +703,7 @@ export class FreeRoomRunner {
|
|
|
677
703
|
async reserve(member, record) {
|
|
678
704
|
let execution;
|
|
679
705
|
const profile = this.config.participants[member.name];
|
|
680
|
-
const cwd = profile && !capabilities_of(profile.adapter).project ? '' : cwd_key(profile?.cwd
|
|
706
|
+
const cwd = profile && !capabilities_of(profile.adapter).project ? '' : cwd_key(profile?.cwd ?? this.config.cwd);
|
|
681
707
|
try { execution = await api(this.config, '/executions', { member_id: member.id, client_id: record.client_id, cwd }); }
|
|
682
708
|
catch (error) {
|
|
683
709
|
if (/HTTP (404|409)/.test(error.message)) return this.save(member.name, { status: 'idle', error: '' });
|
|
@@ -697,10 +723,13 @@ export class FreeRoomRunner {
|
|
|
697
723
|
}
|
|
698
724
|
if (!adapter) return this.fail(member.name, execution.id, `未配置 ${member.name} 的本机接入`);
|
|
699
725
|
if (this.context_errors.has(member.name)) return this.fail(member.name, execution.id, this.context_errors.get(member.name));
|
|
700
|
-
const directory = capabilities_of(adapter.adapter).project ? adapter.cwd
|
|
726
|
+
const directory = capabilities_of(adapter.adapter).project ? adapter.cwd ?? this.config.cwd : undefined;
|
|
701
727
|
// A read-only member's software cannot write the project, so files changing meanwhile were written by someone else.
|
|
702
728
|
const before = adapter.project_access === 'read' ? null : workspace_snapshot(directory);
|
|
703
|
-
|
|
729
|
+
// The room's other directories this member may write are watched as well; their changes are reported with full paths.
|
|
730
|
+
const extras = member_extra_directories(this.config, adapter).filter(item => item.access === 'write')
|
|
731
|
+
.map(item => ({ directory: item.path, before: workspace_snapshot(item.path) })).filter(item => item.before);
|
|
732
|
+
if (before || extras.length) this.snapshots.set(execution.id, { member: member.name, directory, before, extras });
|
|
704
733
|
const binding = this.store.binding(member.name);
|
|
705
734
|
// A member whose session a rewind restarted is bound to '' and must not fall back to the session it was invited with.
|
|
706
735
|
if (binding.session_id === undefined && adapter.session_id) binding.session_id = adapter.session_id;
|
|
@@ -731,7 +760,7 @@ export class FreeRoomRunner {
|
|
|
731
760
|
...(review_request ? { before_model_start: () => this.check_review_delivery(member, review_request) } : {}),
|
|
732
761
|
api_context: { site: this.config.site, room: this.config.room, token: this.config.members[member.name].token,
|
|
733
762
|
home: this.config.api_home, mode: 'free', role: member.role || 'member', execution_id: execution.id,
|
|
734
|
-
cwd: directory, project_access: adapter.project_access,
|
|
763
|
+
cwd: directory, project_access: adapter.project_access, extra_directories: task.extra_directories,
|
|
735
764
|
requests: execution.requests, read_after: execution.read_until, session, discussion_request: session_request,
|
|
736
765
|
scheduler_suggestions, review_handoffs, ...(task.workflow ? { workflow: task.workflow } : {}),
|
|
737
766
|
...(Number.isInteger(this.config.api_max_requests) ? { max_requests: this.config.api_max_requests } : {}),
|
|
@@ -760,7 +789,8 @@ export class FreeRoomRunner {
|
|
|
760
789
|
if (result.session_id) this.store.bind(member.name, result.session_id);
|
|
761
790
|
this.clear_stopped(member.name);
|
|
762
791
|
this.save(member.name, { status: result.status === 'sidecar_pending' ? 'sidecar_pending' : 'running', attempts: 0 });
|
|
763
|
-
|
|
792
|
+
if (result.steer) this.steers.set(execution.id, result.steer);
|
|
793
|
+
await this.report_delivery(execution.id, { ...(result.capabilities || capabilities_of(adapter.adapter).delivery), steer: Boolean(result.steer) }, member.name);
|
|
764
794
|
} catch (error) {
|
|
765
795
|
if (review_request && error.delivery_status === 'not_sent') return this.reject_review(member, execution, error.message);
|
|
766
796
|
if (adapter.project_access === 'own') return this.retry_or_fail(member.name, execution, `投递失败:${error.message}`);
|
|
@@ -874,9 +904,9 @@ export class FreeRoomRunner {
|
|
|
874
904
|
* progress. Kept locally until the execution ends so a restarted runner still knows whether it could stop it.
|
|
875
905
|
* A lost report is retained for the final stop result; sites and runners must use the same capability protocol.
|
|
876
906
|
*/
|
|
877
|
-
async report_delivery(execution_id, { stoppable, progress }, member) {
|
|
907
|
+
async report_delivery(execution_id, { stoppable, progress, steer = false }, member) {
|
|
878
908
|
this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)').run(`delivery:${execution_id}`, JSON.stringify({ stoppable, progress, member }));
|
|
879
|
-
await api(this.config, `/executions/${execution_id}`, { action: 'delivery', stoppable, progress })
|
|
909
|
+
await api(this.config, `/executions/${execution_id}`, { action: 'delivery', stoppable, progress, steer })
|
|
880
910
|
.catch(error => this.log(`执行 #${execution_id} 的投递能力未能上报:${error.message}`));
|
|
881
911
|
}
|
|
882
912
|
/** Retry durable stop reports even after the member went idle, left, or started a later execution. */
|
|
@@ -971,9 +1001,13 @@ export class FreeRoomRunner {
|
|
|
971
1001
|
for (const [execution_id, snapshot] of this.snapshots) {
|
|
972
1002
|
if (active.has(execution_id) || this.execution_processes.get(execution_id)?.size) continue;
|
|
973
1003
|
this.snapshots.delete(execution_id);
|
|
974
|
-
const after = workspace_snapshot(snapshot.directory);
|
|
975
|
-
if (!after) continue;
|
|
976
|
-
const
|
|
1004
|
+
const after = snapshot.before ? workspace_snapshot(snapshot.directory) : null;
|
|
1005
|
+
if (!after && !snapshot.extras?.length) continue;
|
|
1006
|
+
const extra = (snapshot.extras || []).flatMap(item => {
|
|
1007
|
+
const current = workspace_snapshot(item.directory);
|
|
1008
|
+
return current ? changed_between(item.before, current).map(path => extra_change(item.directory, path)) : [];
|
|
1009
|
+
});
|
|
1010
|
+
const changes = [...(after ? changed_between(snapshot.before, after) : []), ...extra].slice(0, 200);
|
|
977
1011
|
// `edited` is sent only when the adapter tracked edits; without it the site cannot tell who wrote a shared file.
|
|
978
1012
|
const edited = snapshot.edited ? { edited: edited_changes(changes, snapshot.edited) } : {};
|
|
979
1013
|
try { await api(this.config, `/executions/${execution_id}`, { action: 'report', changes, ...edited }); }
|
|
@@ -1008,6 +1042,31 @@ export class FreeRoomRunner {
|
|
|
1008
1042
|
this.save(name, { status: 'idle', error: reason });
|
|
1009
1043
|
this.stop_member(name);
|
|
1010
1044
|
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Hand the owner's pending interjections to the software running each execution, in order, through the steer handle
|
|
1047
|
+
* its delivery returned; report each as delivered or failed. A handle is dropped once its execution is no longer live.
|
|
1048
|
+
*/
|
|
1049
|
+
async deliver_interjections(executions) {
|
|
1050
|
+
const live = new Set(executions.map(item => item.id));
|
|
1051
|
+
for (const id of this.steers.keys()) if (!live.has(id)) this.steers.delete(id);
|
|
1052
|
+
for (const execution of executions) {
|
|
1053
|
+
for (const interjection of execution.interjections || []) {
|
|
1054
|
+
// An interjection is handed over once; a lost report is sent again with the same outcome.
|
|
1055
|
+
let report = this.interjecting.get(interjection.id);
|
|
1056
|
+
if (!report) {
|
|
1057
|
+
const steer = this.steers.get(execution.id);
|
|
1058
|
+
report = { status: 'failed', reason: '本次投递不支持执行中插话' };
|
|
1059
|
+
if (steer) {
|
|
1060
|
+
try { report = await steer(interjection.text) ? { status: 'delivered' } : { status: 'failed', reason: '它已结束本轮,插话未送达' }; }
|
|
1061
|
+
catch (error) { report = { status: 'failed', reason: String(error.message).slice(0, 400) }; }
|
|
1062
|
+
}
|
|
1063
|
+
this.interjecting.set(interjection.id, report);
|
|
1064
|
+
}
|
|
1065
|
+
try { await api(this.config, `/executions/${execution.id}`, { action: 'interjection', interjection_id: interjection.id, ...report }); }
|
|
1066
|
+
catch (error) { this.log(`插话 #${interjection.id} 的结果未能上报:${error.message}`); }
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1011
1070
|
/** Count live processes per member so one session never has two writers. */
|
|
1012
1071
|
track(name, child, execution_id = this.row(name)?.execution_id) {
|
|
1013
1072
|
this.children.add(child);
|
package/runner/project_files.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
/** Read-only directory and text previews, confined to a room's saved project
|
|
1
|
+
/** Read-only directory and text previews, confined to a room's saved project directories. */
|
|
2
2
|
import { closeSync, fstatSync, openSync, readdirSync, readSync, realpathSync, statSync } from 'node:fs';
|
|
3
3
|
import { isAbsolute, relative, resolve } from 'node:path';
|
|
4
|
+
import { adapter_profile } from './adapters/profiles.mjs';
|
|
4
5
|
import { PINYIN_TABLE } from './pinyin_table.mjs';
|
|
5
6
|
|
|
6
7
|
const DIRECTORY_PAGE_SIZE = 200;
|
|
@@ -113,7 +114,7 @@ function search_rank(name, path, needle) {
|
|
|
113
114
|
return [tier, path.split('/').length, path.length];
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
/** Find files and folders whose name (or path, when the query has a "/") contains the query, case-insensitively. */
|
|
117
|
+
/** Find files and folders whose name (or path, when the query has a "/") contains the query, case-insensitively, under one root. */
|
|
117
118
|
function search_project_files(root, query) {
|
|
118
119
|
const needle = query.trim().toLocaleLowerCase().replaceAll('\\', '/');
|
|
119
120
|
if (!needle) throw new Error('搜索关键词不能为空');
|
|
@@ -139,10 +140,52 @@ function search_project_files(root, query) {
|
|
|
139
140
|
}
|
|
140
141
|
}
|
|
141
142
|
}
|
|
143
|
+
return { matches, complete };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Rank and cut the matches of every searched directory together; each entry keeps the `root` it was found in. */
|
|
147
|
+
function search_result(query, searches) {
|
|
148
|
+
const needle = query.trim().toLocaleLowerCase().replaceAll('\\', '/');
|
|
149
|
+
const matches = searches.flatMap(({ matches: found }, root) => found.map(item => ({ ...item, root })));
|
|
142
150
|
const ranked = matches.map(item => ({ item, rank: search_rank(item.name, item.path, needle) }))
|
|
143
151
|
.sort((left, right) => left.rank[0] - right.rank[0] || left.rank[1] - right.rank[1] || left.rank[2] - right.rank[2]);
|
|
144
152
|
return { query, kind: 'search', entries: ranked.slice(0, SEARCH_RESULT_LIMIT).map(({ item }) => item),
|
|
145
|
-
total: matches.length, complete };
|
|
153
|
+
total: matches.length, complete: searches.every(search => search.complete) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The room's project directories: the main one (`cwd`) first, then the extra ones in the order they were added. */
|
|
157
|
+
export function room_directories(config) {
|
|
158
|
+
return [config.cwd, ...(config.extra_directories || [])].filter(Boolean);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Whether `directory` is one of the room's project directories or lies inside one; Windows paths compare without case. */
|
|
162
|
+
export function inside_room(config, directory) {
|
|
163
|
+
const key = path => process.platform === 'win32' ? resolve(path).toLowerCase() : resolve(path);
|
|
164
|
+
return room_directories(config).some(root => {
|
|
165
|
+
const rest = relative(key(root), key(directory));
|
|
166
|
+
return !rest.startsWith('..') && !isAbsolute(rest);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The directory a member works in when it lies outside every project directory of the room, or '' when it works in
|
|
172
|
+
* one of them (or the room has none). Such a member only reads the room's directories. A member whose `cwd` is ''
|
|
173
|
+
* joined before the room had a project: it keeps its own session's folder and is not outside.
|
|
174
|
+
*/
|
|
175
|
+
export function outside_directory(config, profile) {
|
|
176
|
+
const cwd = profile?.cwd ?? config.cwd;
|
|
177
|
+
return cwd && room_directories(config).length && !inside_room(config, cwd) ? cwd : '';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether the room's directories stay read-only for this member by the software's own limits, not only by its task
|
|
182
|
+
* text: at 只读 for any software whose levels the runner sets, at 可写 only where a sandbox or the file tools hold
|
|
183
|
+
* (`limits_directories`); never for an App, a manual member's own permissions, 自动 or 全放开.
|
|
184
|
+
*/
|
|
185
|
+
export function read_only_enforced(profile) {
|
|
186
|
+
const facts = adapter_profile(profile?.adapter);
|
|
187
|
+
if (!facts?.access_levels) return false;
|
|
188
|
+
return profile.project_access === 'read' || (profile.project_access === 'write' && Boolean(facts.capabilities.limits_directories));
|
|
146
189
|
}
|
|
147
190
|
|
|
148
191
|
/** The room's project root, resolved through links and required to be a folder. */
|
|
@@ -153,9 +196,12 @@ function project_root(cwd) {
|
|
|
153
196
|
return root;
|
|
154
197
|
}
|
|
155
198
|
|
|
156
|
-
/**
|
|
199
|
+
/**
|
|
200
|
+
* List one directory page or preview one file under one project directory, or search names under it; a search over
|
|
201
|
+
* several directories is `search_project_roots`.
|
|
202
|
+
*/
|
|
157
203
|
export function read_project_files(cwd, path = '', offset = 0, query = '') {
|
|
158
|
-
if (query) return
|
|
204
|
+
if (query) return search_project_roots([cwd], query);
|
|
159
205
|
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('目录分页位置无效');
|
|
160
206
|
const root = project_root(cwd);
|
|
161
207
|
const target = project_path(root, path);
|
|
@@ -176,3 +222,9 @@ export function read_project_files(cwd, path = '', offset = 0, query = '') {
|
|
|
176
222
|
return { path: normalized, kind: 'directory', entries, total: items.length,
|
|
177
223
|
next_offset: offset + entries.length < items.length ? offset + entries.length : null };
|
|
178
224
|
}
|
|
225
|
+
|
|
226
|
+
/** Search names under every directory of a room at once; each entry's `root` is the index of its directory. */
|
|
227
|
+
export function search_project_roots(directories, query) {
|
|
228
|
+
if (!directories.length) project_root('');
|
|
229
|
+
return search_result(String(query), directories.map(directory => search_project_files(project_root(directory), String(query))));
|
|
230
|
+
}
|
package/runner/relay_channel.mjs
CHANGED
|
@@ -49,12 +49,13 @@ export function bridge_token(home, site) {
|
|
|
49
49
|
const path = join(home, 'site-connections.json');
|
|
50
50
|
const hashed = createHash('sha256').update(link.bridge_token).digest('hex');
|
|
51
51
|
const settings = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { sites: {} };
|
|
52
|
-
//
|
|
53
|
-
|
|
52
|
+
// The entry also tells the local website that this site reaches it only through the relay, so a browser connection
|
|
53
|
+
// credential kept from before no longer works. A website disconnected meanwhile has lost its pairing: no bridge then.
|
|
54
|
+
if (settings.sites[site]?.bridge_hash !== hashed) {
|
|
54
55
|
const release = acquire_lock(join(home, 'connection-lock'));
|
|
55
56
|
try {
|
|
56
57
|
const latest = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { sites: {} };
|
|
57
|
-
if (
|
|
58
|
+
if (machine_links(home).sites[site]) atomic_json(path, { ...latest, sites: { ...latest.sites, [site]: { ...latest.sites[site], bridge_hash: hashed } } });
|
|
58
59
|
} finally { release(); }
|
|
59
60
|
}
|
|
60
61
|
return link.bridge_token;
|