bothouse-dsh 0.1.0 → 0.1.40
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/client/client.js +18267 -648
- package/collab/approvals.mjs +177 -0
- package/collab/attachments.mjs +144 -0
- package/collab/avatar-uploads.mjs +53 -0
- package/collab/avatars.mjs +1 -1
- package/collab/cli-member.mjs +117 -32
- package/collab/cloudweb.mjs +173 -114
- package/collab/dsh-member.mjs +588 -44
- package/collab/gate-context.mjs +80 -0
- package/collab/handoff-rules.mjs +122 -0
- package/collab/hub.mjs +23 -4
- package/collab/later.mjs +146 -0
- package/collab/memory-index.mjs +61 -0
- package/collab/mentions.mjs +8 -0
- package/collab/page.html +24 -3
- package/collab/personas.mjs +4 -0
- package/collab/plugin.mjs +385 -32
- package/collab/quota-cooling.mjs +169 -0
- package/collab/roster.mjs +81 -16
- package/collab/store.mjs +220 -45
- package/collab/tasks.mjs +646 -38
- package/collab/team-exit.mjs +189 -11
- package/collab/verify-multichannel.mjs +42 -0
- package/package.json +13 -3
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Claude reports a provider-local reset clock; convert it using its IANA zone,
|
|
5
|
+
// including calendar dates for weekly limits. Missing deadlines stay unknown.
|
|
6
|
+
function resetClock(raw, now) {
|
|
7
|
+
const match = /^(?:([A-Za-z]{3})\s+(\d+),?\s+)?(\d{1,2})(?::(\d{2}))?(am|pm)?\s+\(([^)]+)\)$/i.exec(raw);
|
|
8
|
+
if (!match) return undefined;
|
|
9
|
+
const [, month, day, hour, minute, period, timeZone] = match;
|
|
10
|
+
const fmt = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', hourCycle: 'h23' });
|
|
11
|
+
const parts = at => Object.fromEntries(fmt.formatToParts(at).filter(p => p.type !== 'literal').map(p => [p.type, Number(p.value)]));
|
|
12
|
+
const current = parts(now);
|
|
13
|
+
const h = period ? Number(hour) % 12 + (/pm/i.test(period) ? 12 : 0) : Number(hour);
|
|
14
|
+
if (h > 23 || Number(minute ?? 0) > 59) return undefined;
|
|
15
|
+
const m = month ? 'jan feb mar apr may jun jul aug sep oct nov dec'.split(' ').indexOf(month.toLowerCase()) + 1 : current.month;
|
|
16
|
+
if (!m) return undefined;
|
|
17
|
+
const local = Date.UTC(current.year, m - 1, Number(day ?? current.day), h, Number(minute ?? 0));
|
|
18
|
+
const utc = local => {
|
|
19
|
+
let at = local;
|
|
20
|
+
for (let i = 0; i < 3; i++) {
|
|
21
|
+
const p = parts(at);
|
|
22
|
+
at += local - Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
|
|
23
|
+
}
|
|
24
|
+
return at;
|
|
25
|
+
};
|
|
26
|
+
let at = utc(local);
|
|
27
|
+
if (at <= now) at = utc(month ? Date.UTC(current.year + 1, m - 1, Number(day), h, Number(minute ?? 0)) : local + 86400000);
|
|
28
|
+
return at;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Same deadline and directory as bridge/core/quota_cooling.go. */
|
|
32
|
+
export function quotaDeadline(event, now = Date.now()) {
|
|
33
|
+
const data = event?.data ?? event;
|
|
34
|
+
const report = data?.usage ?? data?.quota;
|
|
35
|
+
let until = 0;
|
|
36
|
+
for (const bucket of report?.buckets ?? report?.Buckets ?? []) {
|
|
37
|
+
for (const w of bucket.windows ?? bucket.Windows ?? []) {
|
|
38
|
+
const used = w.usedPercent ?? w.used_percent ?? w.UsedPercent;
|
|
39
|
+
const seconds = w.windowSeconds ?? w.window_seconds ?? w.WindowSeconds;
|
|
40
|
+
// Only a reported limit counts: a window merely near its limit still has usable quota
|
|
41
|
+
// (user 2026-10-28「或者报错再交接,提前量太多,有点浪费了」).
|
|
42
|
+
if (!(bucket.limitReached || bucket.LimitReached || used >= 100)) continue;
|
|
43
|
+
const at = (w.resetAtUnix ?? w.reset_at_unix ?? w.ResetAtUnix) * 1000 ||
|
|
44
|
+
now + (w.resetAfterSeconds ?? w.reset_after_seconds ?? w.ResetAfterSeconds ?? 0) * 1000;
|
|
45
|
+
if (at > now) until = Math.max(until, at);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const reset = data?.metadata?.quota_exhausted;
|
|
49
|
+
const text = [data?.reason?.error?.message, data?.error?.message, typeof data?.error === 'string' ? data.error : '', reset].filter(Boolean).join(' ');
|
|
50
|
+
if (/quota|resource_exhausted|(?:usage|session) limit|hit your limit/i.test(text) || reset !== undefined) {
|
|
51
|
+
const duration = /resets in (?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?/i.exec(text);
|
|
52
|
+
const delay = duration && (Number(duration[1] ?? 0) * 3600 + Number(duration[2] ?? 0) * 60 + Number(duration[3] ?? 0)) * 1000;
|
|
53
|
+
const raw = reset ?? /resets?\s+(.+?\([^)]+\))/i.exec(text)?.[1] ?? '';
|
|
54
|
+
let absolute;
|
|
55
|
+
try { absolute = /^\d{4}-\d\d-\d\dT/.test(raw) ? Date.parse(raw) : resetClock(raw, now); }
|
|
56
|
+
catch (error) { if (!(error instanceof RangeError)) throw error; } // invalid external IANA zone stays unknown
|
|
57
|
+
if (delay > 0) until = Math.max(until, now + delay);
|
|
58
|
+
if (absolute > now) until = Math.max(until, absolute);
|
|
59
|
+
}
|
|
60
|
+
return until || undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class QuotaCooling {
|
|
64
|
+
constructor(hub, tasks, wake, directory, { now = Date.now } = {}) {
|
|
65
|
+
this.hub = hub; this.tasks = tasks; this.wake = wake; this.directory = directory; this.now = now;
|
|
66
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
67
|
+
}
|
|
68
|
+
read(id, receipt = false) {
|
|
69
|
+
try { return JSON.parse(readFileSync(join(this.directory, `${id}${receipt ? '.takeover' : ''}.json`), 'utf8')); }
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code !== 'ENOENT') this.hub.logger?.warn?.('quota: cannot read %s: %s', id, error.message);
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
save(id, state, receipt = false) {
|
|
76
|
+
const path = join(this.directory, `${id}${receipt ? '.takeover' : ''}.json`), temp = `${path}.tmp-${process.pid}`;
|
|
77
|
+
writeFileSync(temp, JSON.stringify(state), { mode: 0o600 }); renameSync(temp, path);
|
|
78
|
+
}
|
|
79
|
+
cooling(id) { return Date.parse(this.read(id).cooling_until ?? '') > this.now(); }
|
|
80
|
+
observe(id, room, event) {
|
|
81
|
+
// Bridge-backed roles own their cooldown file in Go; the host only reads it.
|
|
82
|
+
if (this.hub.members.get(id)?.kind !== 'dsh') return;
|
|
83
|
+
const until = quotaDeadline(event, this.now());
|
|
84
|
+
if (!until) return;
|
|
85
|
+
const state = this.read(id);
|
|
86
|
+
if (Date.parse(state.cooling_until ?? '') >= until) return;
|
|
87
|
+
state.cooling_until = new Date(until).toISOString();
|
|
88
|
+
state.session = `cloud_web:room-${room}`;
|
|
89
|
+
this.save(id, state);
|
|
90
|
+
}
|
|
91
|
+
candidate(room, title, from, reserved) {
|
|
92
|
+
const code = /代码|编译|修复|code|\bPR\b/i.test(title);
|
|
93
|
+
const rank = m => code ? /longniang|龙娘/.test(m.id + m.name) ? 0 : 1 : /jingyu|鲸鱼/.test(m.id + m.name) ? 0 : 1;
|
|
94
|
+
const chat = this.hub.store.settingsRoom(room);
|
|
95
|
+
const busy = id => [...this.hub.thinking].some(([r, ids]) => ids.has(id) && this.hub.store.settingsRoom(r) === chat) ||
|
|
96
|
+
this.tasks.activeTasksOf(id).some(t => this.hub.store.settingsRoom(t.room_id) === chat);
|
|
97
|
+
return this.hub.routableIn(room).filter(m => m.id !== from && m.online !== false && !this.cooling(m.id) &&
|
|
98
|
+
!busy(m.id) && !reserved.has(m.id))
|
|
99
|
+
.sort((a,b) => rank(a) - rank(b) || a.id.localeCompare(b.id))[0];
|
|
100
|
+
}
|
|
101
|
+
notice(room, key, text) {
|
|
102
|
+
return this.hub.post(room, { authorKind: 'system', author: 'system', name: '额度交接', clientKey: key, text }).message;
|
|
103
|
+
}
|
|
104
|
+
tick() {
|
|
105
|
+
for (const member of this.hub.routable()) {
|
|
106
|
+
const quota = this.read(member.id);
|
|
107
|
+
let state = this.read(member.id, true);
|
|
108
|
+
if (!quota.cooling_until && !state.until) continue;
|
|
109
|
+
if (!this.cooling(member.id)) {
|
|
110
|
+
for (const [room, lead] of Object.entries(state.leads ?? {})) {
|
|
111
|
+
if (this.hub.primaryIn(Number(room))?.id === lead.to) {
|
|
112
|
+
this.hub.store.setLead(Number(room), member.id);
|
|
113
|
+
this.notice(Number(room), `${member.id}-${state.until}-restore-${room}`, `${member.name} 额度恢复,主角色已切回。`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (state.until) {
|
|
117
|
+
this.save(member.id, {}, true);
|
|
118
|
+
this.hub.emit({ type: 'rooms', rooms: this.hub.store.roomsView() });
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
// Extending a cooldown retains its receipts; a recovered generation starts fresh.
|
|
123
|
+
if (!state.until) state = { until: quota.cooling_until, leads: {}, transfers: {} };
|
|
124
|
+
const key = `${member.id}-${state.until}`;
|
|
125
|
+
const reserved = new Set(Object.values(state.transfers).filter(t => !t.woken).map(t => t.to));
|
|
126
|
+
for (const room of this.hub.store.rooms().filter(r => r.kind !== 'workspace')) {
|
|
127
|
+
if (state.leads[room.id] || this.hub.primaryIn(room.id)?.id !== member.id) continue;
|
|
128
|
+
const target = this.candidate(room.id, '', member.id, reserved);
|
|
129
|
+
if (!target) continue;
|
|
130
|
+
state.leads[room.id] = { to: target.id, woken: false };
|
|
131
|
+
this.save(member.id, state, true);
|
|
132
|
+
this.hub.store.setLead(room.id, target.id);
|
|
133
|
+
this.hub.emit({ type: 'rooms', rooms: this.hub.store.roomsView() });
|
|
134
|
+
}
|
|
135
|
+
for (const [room, lead] of Object.entries(state.leads)) {
|
|
136
|
+
if (lead.woken || this.cooling(lead.to)) continue;
|
|
137
|
+
// Replay a crash between persisting intent and changing the selection.
|
|
138
|
+
if (this.hub.primaryIn(Number(room))?.id === member.id) this.hub.store.setLead(Number(room), lead.to);
|
|
139
|
+
if (this.hub.primaryIn(Number(room))?.id !== lead.to) continue;
|
|
140
|
+
const msg = this.notice(Number(room), `${key}-lead-${room}`, `${member.name} 额度冷却,主角色交给 ${this.tasks.nameOf(lead.to)}。请接着推进未完成任务。`);
|
|
141
|
+
if (this.wake(lead.to, msg)) { lead.woken = true; this.save(member.id, state, true); }
|
|
142
|
+
}
|
|
143
|
+
for (const task of this.tasks.activeTasksOf(member.id)) {
|
|
144
|
+
if (state.transfers[task.id] || this.tasks.awaitingUser(task)) continue;
|
|
145
|
+
// These rows mirror bridge-owned cards/workspaces. Go moves the original
|
|
146
|
+
// journal and wakes its replacement; the next card update mirrors ownership.
|
|
147
|
+
if (member.kind === 'bot' && ['card', 'workspace'].includes(task.source)) continue;
|
|
148
|
+
const target = this.candidate(task.room_id, task.title, member.id, reserved);
|
|
149
|
+
if (!target) continue;
|
|
150
|
+
state.transfers[task.id] = { to: target.id, room: task.room_id, title: task.title, reason: task.reason, ref: task.ref, base: task.base, woken: false };
|
|
151
|
+
this.save(member.id, state, true);
|
|
152
|
+
this.tasks.reassignForQuota(task, target.id);
|
|
153
|
+
reserved.add(target.id);
|
|
154
|
+
}
|
|
155
|
+
for (const [id, transfer] of Object.entries(state.transfers)) {
|
|
156
|
+
if (transfer.woken || this.cooling(transfer.to)) continue;
|
|
157
|
+
const task = this.tasks.q.get.get(Number(id));
|
|
158
|
+
if (task?.owner === member.id) this.tasks.reassignForQuota(task, transfer.to);
|
|
159
|
+
if (this.tasks.q.get.get(Number(id))?.owner !== transfer.to) continue;
|
|
160
|
+
const msg = this.notice(transfer.room, `${key}-task-${id}`, `接手 #${id}「${transfer.title}」。原交接:${transfer.reason ?? ''}\n参照:${transfer.ref ?? ''}\n底分支:${transfer.base ?? ''}`);
|
|
161
|
+
if (this.wake(transfer.to, msg)) { transfer.woken = true; this.save(member.id, state, true); }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
start() { this.tick(); this.timer = setInterval(() => this.tick(), 2000); this.timer.unref(); }
|
|
166
|
+
close() { clearInterval(this.timer); }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const quotaDirectory = leadFile => join(dirname(leadFile), 'quota-cooling');
|
package/collab/roster.mjs
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// in the UI), UI members are created, edited and removed here. Names must be
|
|
3
3
|
// unique across every channel member (DSH members and cc-connect bots alike)
|
|
4
4
|
// because @-mentions are matched by name.
|
|
5
|
-
import { existsSync, readFileSync,
|
|
5
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { CollabError } from './store.mjs';
|
|
7
7
|
import { checkAvatar } from './avatars.mjs';
|
|
8
|
+
import { PERMISSION_PRESETS } from './cli-member.mjs';
|
|
8
9
|
|
|
9
10
|
const MAX_NAME = 32;
|
|
10
11
|
const MAX_PERSONA = 4000;
|
|
@@ -18,10 +19,13 @@ export function rowToMember(row) {
|
|
|
18
19
|
return {
|
|
19
20
|
id: row.id,
|
|
20
21
|
name: row.name,
|
|
22
|
+
...(row.name_customized == null ? {} : { nameCustomized: Boolean(row.name_customized) }),
|
|
21
23
|
...(row.provider && row.model ? { model: { provider: row.provider, model: row.model } } : {}),
|
|
22
24
|
...(row.cli ? { cli: row.cli } : {}),
|
|
25
|
+
...(row.cli && row.permission_preset ? { permissionPreset: row.permission_preset } : {}),
|
|
23
26
|
persona: row.persona ?? '',
|
|
24
27
|
...(row.avatar ? { avatar: row.avatar } : {}),
|
|
28
|
+
...(row.rooms && row.rooms !== '[]' ? { rooms: JSON.parse(row.rooms) } : {}),
|
|
25
29
|
source: row.source,
|
|
26
30
|
};
|
|
27
31
|
}
|
|
@@ -32,7 +36,8 @@ export class Roster {
|
|
|
32
36
|
* @param dsh - DshMembers (runtime routing).
|
|
33
37
|
* @param llm - DSH `ctx.llm` service, or undefined to skip model checks.
|
|
34
38
|
*/
|
|
35
|
-
constructor(hub, dsh, llm) {
|
|
39
|
+
constructor(hub, dsh, llm, avatars) {
|
|
40
|
+
this.avatars = avatars;
|
|
36
41
|
this.hub = hub;
|
|
37
42
|
this.store = hub.store;
|
|
38
43
|
this.dsh = dsh;
|
|
@@ -42,13 +47,20 @@ export class Roster {
|
|
|
42
47
|
/** Write config members as seeds, then publish every active member. */
|
|
43
48
|
load(configMembers) {
|
|
44
49
|
this.store.transaction(() => { for (const m of configMembers) this.store.seedMember(m); });
|
|
45
|
-
|
|
50
|
+
// The members table has no preset columns: a config member's agentPreset and
|
|
51
|
+
// permissionPreset come from the config itself, or they never reach its Sessions.
|
|
52
|
+
const presets = new Map(configMembers.map(m => [m.id, {
|
|
53
|
+
...(m.agentPreset === undefined ? {} : { agentPreset: m.agentPreset }),
|
|
54
|
+
...(m.permissionPreset === undefined ? {} : { permissionPreset: m.permissionPreset }),
|
|
55
|
+
}]));
|
|
56
|
+
for (const row of this.store.members()) this.dsh.putMember({ ...rowToMember(row), ...(row.source === 'ui' ? {} : presets.get(row.id)) });
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
/**
|
|
49
60
|
* Add the roles the setup wizard wrote (bridge core/setup_dsh_configure.go: {"members":[{name,cli?,persona?}]})
|
|
50
|
-
* as UI members, once: names already in the channel are skipped, then the file becomes *.imported.json
|
|
51
|
-
*
|
|
61
|
+
* as UI members, once: names already in the channel are skipped, then the file becomes *.imported.json with
|
|
62
|
+
* each role's member id (the new one, or the one already holding the name), so bothouse names the role's wiki
|
|
63
|
+
* after the id the member really has (bridge core/setup_dsh_wiki.go). Returns the names added.
|
|
52
64
|
*/
|
|
53
65
|
async importWizard(path, logger) {
|
|
54
66
|
if (!existsSync(path)) return [];
|
|
@@ -60,18 +72,28 @@ export class Roster {
|
|
|
60
72
|
return [];
|
|
61
73
|
}
|
|
62
74
|
const added = [];
|
|
75
|
+
const imported = [];
|
|
63
76
|
for (const m of Array.isArray(members) ? members : []) {
|
|
64
77
|
const name = String(m?.name ?? '').trim();
|
|
65
|
-
|
|
78
|
+
const same = this.hub.memberList().find(x => x.name.toLowerCase() === name.toLowerCase());
|
|
79
|
+
// A default-named role may become Codex-2 when Codex is already there; custom and legacy
|
|
80
|
+
// names stay idempotent (a re-import must not duplicate them).
|
|
81
|
+
if (same !== undefined && m?.name_customized !== false) {
|
|
82
|
+
imported.push({ ...m, id: same.id });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
66
85
|
try {
|
|
67
86
|
const avatar = m?.avatar && ['orange', 'whale', 'dragon', 'rainbow', 'lanxi'].includes(m.avatar) ? m.avatar : undefined;
|
|
68
|
-
await this.create({ name, persona: m?.persona ?? '', ...(m?.cli ? { cli: m.cli } : {}), ...(avatar ? { avatar } : {}) });
|
|
69
|
-
|
|
87
|
+
const member = await this.create({ name, persona: m?.persona ?? '', ...(m?.cli ? { cli: m.cli } : {}), ...(m?.name_customized === undefined ? {} : { nameCustomized: Boolean(m.name_customized) }), ...(avatar ? { avatar } : {}) });
|
|
88
|
+
imported.push({ ...m, id: member.id });
|
|
89
|
+
added.push(member.name);
|
|
70
90
|
} catch (error) {
|
|
91
|
+
imported.push(m);
|
|
71
92
|
logger?.warn?.('conest-collab: wizard member %s not added: %s', name, error?.message ?? error);
|
|
72
93
|
}
|
|
73
94
|
}
|
|
74
|
-
|
|
95
|
+
writeFileSync(path.replace(/\.json$/, '') + '.imported.json', JSON.stringify({ members: imported }, null, 2), { mode: 0o600 });
|
|
96
|
+
unlinkSync(path);
|
|
75
97
|
return added;
|
|
76
98
|
}
|
|
77
99
|
|
|
@@ -102,7 +124,12 @@ export class Roster {
|
|
|
102
124
|
const existing = this.store.member(id);
|
|
103
125
|
if (existing === undefined || existing.removed) throw new CollabError('member-missing', `member ${id} does not exist`);
|
|
104
126
|
if (existing.source !== 'ui') throw new CollabError('read-only', `member ${id} comes from the config file and cannot be edited here`);
|
|
105
|
-
const m = await this.validate(input, id);
|
|
127
|
+
const m = await this.validate({ ...input, rooms: input?.rooms === undefined ? JSON.parse(existing.rooms ?? '[]') : input.rooms }, id);
|
|
128
|
+
if (input?.nameCustomized === undefined && existing.name_customized != null) m.nameCustomized = Boolean(existing.name_customized);
|
|
129
|
+
// An edit that leaves out the approval tier keeps the stored one instead of falling back to the default.
|
|
130
|
+
if (m.cli !== undefined && input?.permissionPreset === undefined && existing.cli && existing.permission_preset) {
|
|
131
|
+
m.permissionPreset = existing.permission_preset;
|
|
132
|
+
}
|
|
106
133
|
const row = this.store.updateMember(id, m);
|
|
107
134
|
const member = rowToMember(row);
|
|
108
135
|
this.dsh.putMember(member);
|
|
@@ -113,16 +140,51 @@ export class Roster {
|
|
|
113
140
|
const existing = this.store.member(id);
|
|
114
141
|
if (existing === undefined || existing.removed) throw new CollabError('member-missing', `member ${id} does not exist`);
|
|
115
142
|
if (existing.source !== 'ui') throw new CollabError('read-only', `member ${id} comes from the config file and cannot be removed here`);
|
|
143
|
+
this.avatars?.remove(id);
|
|
116
144
|
this.store.removeMember(id);
|
|
117
145
|
this.dsh.dropMember(id);
|
|
118
146
|
}
|
|
119
147
|
|
|
148
|
+
setAvatar(id, png) {
|
|
149
|
+
const existing = this.store.member(id);
|
|
150
|
+
if (existing === undefined || existing.removed) throw new CollabError('member-missing', `member ${id} does not exist`);
|
|
151
|
+
if (existing.source !== 'ui') throw new CollabError('read-only', '配置文件中的成员不能在这里修改头像');
|
|
152
|
+
let avatar = '';
|
|
153
|
+
if (png === undefined) this.avatars.remove(id);
|
|
154
|
+
else avatar = this.avatars.save(id, png);
|
|
155
|
+
const row = this.store.updateMember(id, { ...rowToMember(existing), avatar });
|
|
156
|
+
const member = rowToMember(row);
|
|
157
|
+
this.dsh.putMember(member);
|
|
158
|
+
return member;
|
|
159
|
+
}
|
|
160
|
+
|
|
120
161
|
async validate(input, selfId) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
162
|
+
let name = String(input?.name ?? '').trim();
|
|
163
|
+
const existing = selfId === undefined ? undefined : this.store.member(selfId);
|
|
164
|
+
const nameCustomized = input?.nameCustomized ?? (existing?.name_customized == null ? undefined : Boolean(existing.name_customized));
|
|
165
|
+
if (nameCustomized !== undefined && typeof nameCustomized !== 'boolean') throw new CollabError('invalid', 'nameCustomized must be boolean');
|
|
166
|
+
const cli = input?.cli ?? existing?.cli;
|
|
167
|
+
const backend = { claude: 'Claude Code', codex: 'Codex', gemini: 'Gemini' }[cli] ?? 'DSH';
|
|
168
|
+
const used = new Set(this.hub.memberList().filter(m => m.id !== selfId).map(m => m.name.toLowerCase()));
|
|
169
|
+
const isNew = existing === undefined;
|
|
170
|
+
if (!name || (nameCustomized === false && (isNew ? used.has(name.toLowerCase()) : existing.cli !== cli))) {
|
|
171
|
+
if (isNew && name) {
|
|
172
|
+
// Keep the requested automatic stem (Codex, Claude Code…) and only move past collisions.
|
|
173
|
+
const stem = name.replace(/-\d+$/, '');
|
|
174
|
+
for (let n = 2; used.has(name.toLowerCase()); n++) name = `${stem}-${n}`;
|
|
175
|
+
} else {
|
|
176
|
+
name = backend;
|
|
177
|
+
for (let n = 2; used.has(name.toLowerCase()); n++) name = `${backend}-${n}`;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!name || name.length > MAX_NAME || /[@<>\r\n\t]/.test(name) || (/\s/.test(name) && !/^Claude Code(?:-\d+)?$/.test(name))) {
|
|
181
|
+
throw new CollabError('invalid', `name must be 1-${MAX_NAME} characters without spaces (except Claude Code), @ or <>`);
|
|
182
|
+
}
|
|
183
|
+
const rooms = input?.rooms ?? [];
|
|
184
|
+
if (!Array.isArray(rooms) || rooms.some(id => !Number.isSafeInteger(id) || this.store.room(id)?.kind !== 'chat')) {
|
|
185
|
+
throw new CollabError('invalid', 'rooms must contain existing chat channel ids');
|
|
124
186
|
}
|
|
125
|
-
const clash = this.hub.memberList().find(m => m.id !== selfId && (m.name.toLowerCase() === name.toLowerCase() || m.id.toLowerCase() === name.toLowerCase()));
|
|
187
|
+
const clash = this.hub.memberList().find(m => m.id !== selfId && (!rooms.length || !m.rooms?.length || rooms.some(id => m.rooms.includes(id))) && (m.name.toLowerCase() === name.toLowerCase() || m.id.toLowerCase() === name.toLowerCase()));
|
|
126
188
|
if (clash !== undefined) throw new CollabError('conflict', `name ${name} is already used by ${clash.name}`);
|
|
127
189
|
const persona = String(input?.persona ?? '');
|
|
128
190
|
if (persona.length > MAX_PERSONA) throw new CollabError('invalid', `persona exceeds ${MAX_PERSONA} characters`);
|
|
@@ -132,7 +194,10 @@ export class Roster {
|
|
|
132
194
|
// A local CLI member brings its own model; no provider route to check.
|
|
133
195
|
const cli = String(input.cli);
|
|
134
196
|
if (!CLIS.includes(cli)) throw new CollabError('invalid', `cli must be one of ${CLIS.join(', ')}`);
|
|
135
|
-
|
|
197
|
+
// Tool approval tier: one of DSH's permission presets; absent = DSH's default (cli-member.mjs).
|
|
198
|
+
const preset = input.permissionPreset == null || input.permissionPreset === '' ? undefined : String(input.permissionPreset);
|
|
199
|
+
if (preset !== undefined && !PERMISSION_PRESETS.includes(preset)) throw new CollabError('invalid', `permissionPreset must be one of ${PERMISSION_PRESETS.join(', ')}`);
|
|
200
|
+
return { name, persona, avatar, nameCustomized, rooms, cli, ...(preset === undefined ? {} : { permissionPreset: preset }) };
|
|
136
201
|
}
|
|
137
202
|
let model;
|
|
138
203
|
if (input?.model != null) {
|
|
@@ -148,7 +213,7 @@ export class Roster {
|
|
|
148
213
|
}
|
|
149
214
|
model = { provider, model: id };
|
|
150
215
|
}
|
|
151
|
-
return { name, persona, avatar, ...(model === undefined ? {} : { model }) };
|
|
216
|
+
return { name, persona, avatar, nameCustomized, rooms, ...(model === undefined ? {} : { model }) };
|
|
152
217
|
}
|
|
153
218
|
|
|
154
219
|
/** A stable, unused member id derived from the name (ASCII) or a short random suffix. */
|