bothouse-dsh 0.1.57 → 0.1.59

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.
@@ -10,7 +10,7 @@ import { randomUUID } from 'node:crypto';
10
10
  import { readFileSync, realpathSync, statSync } from 'node:fs';
11
11
  import { basename, isAbsolute, relative, sep } from 'node:path';
12
12
  import { exitTarget } from './team-exit.mjs';
13
- import { DEFAULT_DISPATCH_MODE, MAX_TEXT } from './store.mjs';
13
+ import { DEFAULT_DISPATCH_MODE, MAX_TEXT, messageAttachments } from './store.mjs';
14
14
  import { ChannelApprovals, DEFAULT_ACTIONS } from './approvals.mjs';
15
15
  import { DEFAULT_PERMISSION_PRESET, presetDecision } from './cli-member.mjs';
16
16
  import { GateContext } from './gate-context.mjs';
@@ -61,7 +61,7 @@ export function memberStatus(hub, tasks = hub?.tasks, roomId) {
61
61
  import { mentions, nameCalled, split } from './mentions.mjs';
62
62
  import { quotePrefix } from './store.mjs';
63
63
  import { progressCardOf } from './cloudweb.mjs';
64
- import { attachmentBlocks, namedAttachmentBlocks, nativeAttachmentBlocks, undeliveredReasonZh } from './attachments.mjs';
64
+ import { attachmentBlocks, namedAttachmentBlocks, nativeAttachmentBlocks, oversizedNote, undeliveredReasonZh } from './attachments.mjs';
65
65
 
66
66
  const CONTEXT_LIMIT = 50;
67
67
  const CLOSE_TIMEOUT_MS = 5_000;
@@ -360,10 +360,31 @@ export class TurnProgress {
360
360
  }
361
361
  }
362
362
 
363
+ /** Text with every @-mention taken out, trimmed: what a command word is matched against. */
364
+ function commandOf(text, members) {
365
+ return split(text, members).filter(([, id]) => id === null).map(([seg]) => seg).join('').trim();
366
+ }
367
+
363
368
  /** Whether text, with @-mentions removed, is the /new command (bridge core cmdNew). */
364
369
  export function isNewCommand(text, members) {
365
- const rest = split(text, members).filter(([, id]) => id === null).map(([seg]) => seg).join('').trim();
366
- return /^\/new(\s|$)/.test(rest);
370
+ return /^\/new(\s|$)/.test(commandOf(text, members));
371
+ }
372
+
373
+ /**
374
+ * The stop command a person typed in a channel: 'one' for `/stop`, 'all' for
375
+ * `/stop all`, undefined for anything else. The @ that addressed it is already
376
+ * gone by the time the message reaches this planner (the bridge forwards a
377
+ * channel command stripped, mentions.mjs forMember), so the members it reaches
378
+ * are the planner's own targets — the same rule /new follows.
379
+ *
380
+ * Not the bridge's /stop all (bridge/core/stop_all.go, every bot in the channel
381
+ * stops its own bridge session): this one only ends DSH members' turns here.
382
+ */
383
+ export function stopCommand(text, members) {
384
+ const rest = commandOf(text, members);
385
+ if (/^\/stop\s+all$/i.test(rest)) return 'all';
386
+ if (/^\/stop$/i.test(rest)) return 'one';
387
+ return undefined;
367
388
  }
368
389
 
369
390
  export class DshMembers {
@@ -387,8 +408,10 @@ export class DshMembers {
387
408
  this.openTurns = new Map();
388
409
  /** Agents whose request hook was installed after they were loaded by someone else. */
389
410
  this.hooked = new WeakSet();
390
- /** JSON([memberId, cwd, roomId]) -> { memberId, cli, agent } live local CLI agents (members with a `cli` field). */
411
+ /** JSON([memberId, cwd, roomId]) -> { memberId, roomId, cli, agent } live local CLI agents. */
391
412
  this.cliAgents = new Map();
413
+ // Releasing a workspace retains its bindings for rework, but needs no resident CLI.
414
+ this.store.onWorkspaceRelease(roomId => this.dropRoomCli(roomId));
392
415
  /** #557: permission cards of channel-started turns, shared with cloud-web bots. */
393
416
  this.approvals = ChannelApprovals.of(hub);
394
417
  /** Session ids whose owner chose 允许所有: later asks in that Session are allowed without a card (CLI members: `cli:<ACP session id>`). */
@@ -410,6 +433,14 @@ export class DshMembers {
410
433
  if (this.config.get(m.id)?.cli !== m.cli) this.dropCli(m.id);
411
434
  this.config.set(m.id, m);
412
435
  this.publish(m);
436
+ // Hub owns scope rules, including workspace inheritance and the member's private chat.
437
+ const removed = (id, roomId) => id === m.id && !this.hub.membersInRoom(roomId).some(member => member.id === m.id);
438
+ this.cancelCliStarts(removed);
439
+ for (const [key, entry] of this.cliAgents) {
440
+ if (!removed(entry.memberId, entry.roomId)) continue;
441
+ entry.agent.close();
442
+ this.cliAgents.delete(key);
443
+ }
413
444
  }
414
445
 
415
446
  /** Stop routing to a member. Pending jobs for it fail when they run; history keeps its name. */
@@ -461,10 +492,22 @@ export class DshMembers {
461
492
  * @returns { stopped, running } member ids whose turn was cancelled / left running.
462
493
  */
463
494
  recall(triggerId) {
495
+ return this.cancelJobs(job => job.trigger_id === triggerId);
496
+ }
497
+
498
+ /**
499
+ * #148 / #299: end every open job `match` accepts. A queued one is closed, one
500
+ * still waiting in the member's inbox is taken back, and a turn already running
501
+ * is cancelled the way DSH's stop button does (keepInbox, so the member's later
502
+ * inputs still run). Jobs it cannot reach (a CLI member's ask) keep running.
503
+ * The one cancel path behind both "the owner withdrew the message" and "/stop".
504
+ * @returns { stopped, running } member ids whose turn was cancelled / left running.
505
+ */
506
+ cancelJobs(match) {
464
507
  const stopped = [];
465
508
  const running = [];
466
509
  for (const job of this.store.openJobs()) {
467
- if (job.trigger_id !== triggerId) continue;
510
+ if (!match(job)) continue;
468
511
  if (job.state === 'pending') { this.store.finishJob(job.id, 'interrupted'); this.syncQueue(job.member_id, job.room_id); continue; }
469
512
  const w = this.watching.get(job.session_id);
470
513
  const agent = job.input_id ? this.rt.ctx.agents.get(job.session_id) : undefined;
@@ -481,6 +524,29 @@ export class DshMembers {
481
524
  return { stopped, running };
482
525
  }
483
526
 
527
+ /**
528
+ * #299: `/stop` / `/stop all` typed in the channel. Cancels member turns in this
529
+ * room on the sender's own thread and posts one receipt saying who stopped and
530
+ * who is still running; it never queues, so it cannot arrive after the turn it
531
+ * is meant to end.
532
+ * @returns { stopped, still_running } member names, as the DELETE route's shape.
533
+ */
534
+ stopHere(msg, memberIds) {
535
+ const { stopped, running } = this.cancelJobs(job => job.room_id === msg.room_id && memberIds.includes(job.member_id));
536
+ const name = id => this.config.get(id)?.name ?? id;
537
+ const still_running = [...new Set(running.map(name))];
538
+ const stoppedNames = [...new Set(stopped.map(name))];
539
+ const text = stoppedNames.length === 0
540
+ ? `⏹ 没有在跑的活。`
541
+ : `⏹ 已停下 ${stoppedNames.join('、')} 这一轮。` + (still_running.length ? `\n仍在跑:${still_running.join('、')}` : '');
542
+ this.hub.post(msg.room_id, {
543
+ authorKind: 'system', author: 'system', name: 'system',
544
+ clientKey: `dsh-stop-${msg.id}-${stoppedNames.length ? 'stopped' : 'idle'}`,
545
+ text,
546
+ });
547
+ return { stopped: stoppedNames, still_running };
548
+ }
549
+
484
550
  /**
485
551
  * #557: root approval/request answerer (installed prepended by the plugin).
486
552
  * The Session is held out of the DSH conversation list, so its approval
@@ -536,6 +602,20 @@ export class DshMembers {
536
602
  const visible = new Set(this.hub.routableIn(msg.room_id).map(m => m.id));
537
603
  const targets = [...this.config.keys()].filter(id => !this.hub.quota?.cooling(id) && visible.has(id) && id !== msg.author && !answered.has(id) && (!target || target === id)
538
604
  && (only !== null ? only.has(id) : hit.has(id) || (leadTarget && id === leadID) || (msg.author_kind === 'role' && nameCalled(msg.text, this.config.get(id).name, [...this.config.keys()].filter(o => o !== id).map(o => this.config.get(o).name)))));
605
+ // #299: /stop and /stop all end the member turns running here, now. Both used to fall through to
606
+ // addJob+enqueue below, which serializes per (member, room) — so the stop only ran once the turn it
607
+ // was meant to end had already finished. They take the same out-of-band cancel path as recall, with
608
+ // no placeholder job and no queue slot, so the receipt cannot arrive late.
609
+ // Not the bridge's /stop all (stop_all.go): that stops every bot's bridge session in the channel.
610
+ const stop = msg.author_kind === 'user' ? stopCommand(msg.text, this.hub.routableIn(msg.room_id)) : undefined;
611
+ // `/stop all` is typed bare, so its targets are none; it stops every DSH member visible here.
612
+ // A single `/stop` only stops the members the message reaches, the same targets /new uses.
613
+ if (stop !== undefined) {
614
+ const ids = stop === 'all' ? [...this.config.keys()].filter(id => visible.has(id)) : targets;
615
+ // Nobody is here to stop: keep quiet, like any other message that reaches no member.
616
+ if (ids.length === 0) return undefined;
617
+ return () => { this.stopHere(msg, ids); };
618
+ }
539
619
  if (targets.length === 0) return undefined;
540
620
  // /new goes to the members the message reaches, as on Discord where only the addressed bot handles it.
541
621
  if (msg.author_kind === 'user' && isNewCommand(msg.text, this.hub.routableIn(msg.room_id))) {
@@ -635,9 +715,11 @@ export class DshMembers {
635
715
  // never advances the cursor, so the same image would block the room.
636
716
  const { parts: media, dropped: refused } = await nativeAttachmentBlocks(this.rt.ctx.attachments, namedAttachmentBlocks(this.rt.files, unseen));
637
717
  const dropped = refused.map(d => `\n\n[${d.name}: 附件未送达(${undeliveredReasonZh(d)})]`).join('');
718
+ // #197: a file over the bot connection cap is not inlined; the member reads it from disk.
719
+ const oversized = oversizedNote(this.rt.files?.oversized(unseen.flatMap(messageAttachments)));
638
720
  const wiki = await this.turnContext(member, room, agent.session.id, unseen);
639
721
  if (this.closed) return;
640
- const input = this.rt.createUserMessage({ content: [{ type: 'text', text: promptText(room, member, unseen, withBlock(memberStatus(this.hub, undefined, room.id), wiki.block), this.store.dispatchMode(room.id), this.store) + dropped + (this.rt.later?.instructions(member.id, room.id) ?? '') + workspaceHint(this.hub, this.rt.later, member.id, room.id) }, ...media], source: { kind: 'user' } });
722
+ const input = this.rt.createUserMessage({ content: [{ type: 'text', text: promptText(room, member, unseen, withBlock(memberStatus(this.hub, undefined, room.id), wiki.block), this.store.dispatchMode(room.id), this.store) + dropped + (oversized ? `\n${oversized}` : '') + (this.rt.later?.instructions(member.id, room.id) ?? '') + workspaceHint(this.hub, this.rt.later, member.id, room.id) }, ...media], source: { kind: 'user' } });
641
723
  const seqBefore = agent.session.seq;
642
724
  if (!this.store.markSent(jobId, agent.session.id, seqBefore, input.id)) return;
643
725
  wiki.commit();
@@ -678,7 +760,7 @@ export class DshMembers {
678
760
  let entry = this.cliAgents.get(cliKey);
679
761
  if (entry === undefined) {
680
762
  // Persona goes through promptText every turn, so an edit applies without restarting the CLI.
681
- entry = { memberId: member.id, cli: member.cli, agent: this.rt.createCliMember({ cli: member.cli, cwd, persona: '',
763
+ entry = { memberId: member.id, roomId: room.id, cli: member.cli, agent: this.rt.createCliMember({ cli: member.cli, cwd, persona: '',
682
764
  // #1020: memvote.py records citations under these (memory-sources.mjs), named as the gate key (gate-context.mjs).
683
765
  env: { CC_PROJECT: `dsh:${member.id}`, CC_SESSION_KEY: `dsh:${member.id}:${room.id}` } }) };
684
766
  this.cliAgents.set(cliKey, entry);
@@ -757,7 +839,12 @@ export class DshMembers {
757
839
  }
758
840
  };
759
841
  const prompt = promptText(room, member, unseen, withBlock(memberStatus(this.hub, undefined, room.id), wiki.block), this.store.dispatchMode(room.id), this.store) + (this.rt.later?.instructions(member.id, room.id) ?? '') + workspaceHint(this.hub, this.rt.later, member.id, room.id);
760
- const text = (await entry.agent.ask(prompt, String(room.id), onText, onTool, { resumeId, onSession, onPermission, cliConfig: member.cliConfig, cliConfigManaged: member.cliConfigManaged, attachments: attachmentBlocks(this.rt.files, unseen) }))?.trim();
842
+ // #197 合 develop: 这里不能再提前 wiki.commit()。develop 的 af2d1afe 把「记忆已送达」
843
+ // 挪进 onSession 回调,因为 cli-member.mjs 先建会话、再应用 CLI 配置,才触发 onSession;
844
+ // 这两步失败时提前标记会让重试跳过 MEMORY.md。记忆目录是否真的送达由 onSession 里的调用决定。
845
+ // #197: a file over the bot connection cap is not inlined; the CLI reads it from disk.
846
+ const bigNote = oversizedNote(this.rt.files?.oversized(unseen.flatMap(messageAttachments)));
847
+ const text = (await entry.agent.ask(`${prompt}${bigNote ? `\n${bigNote}` : ''}`, String(room.id), onText, onTool, { resumeId, onSession, onPermission, cliConfig: member.cliConfig, cliConfigManaged: member.cliConfigManaged, attachments: attachmentBlocks(this.rt.files, unseen) }))?.trim();
761
848
  if (this.closed) return;
762
849
  this.hub.quota?.observe(member.id, room.id, { error: text });
763
850
  if (!text) { this.fail(job.id, `${member.name} 这一轮没有给出回复(${member.cli} 没有输出文字)`); return; }
@@ -803,13 +890,20 @@ export class DshMembers {
803
890
  resetRoom(roomId) {
804
891
  this.gateContext.resetRoom(roomId);
805
892
  this.memoryIndex.resetRoom(roomId);
806
- for (const { agent } of this.cliAgents.values()) {
807
- agent.sessions?.delete(String(roomId));
808
- agent.saved?.delete(String(roomId));
893
+ this.dropRoomCli(roomId);
894
+ }
895
+
896
+ dropRoomCli(roomId) {
897
+ this.cancelCliStarts((_memberId, id) => id === roomId);
898
+ for (const [key, entry] of this.cliAgents) {
899
+ if (entry.roomId !== roomId) continue;
900
+ entry.agent.close();
901
+ this.cliAgents.delete(key);
809
902
  }
810
903
  }
811
904
 
812
905
  dropCli(id) {
906
+ this.cancelCliStarts(memberId => memberId === id);
813
907
  for (const [key, entry] of this.cliAgents) {
814
908
  if (entry.memberId !== id) continue;
815
909
  entry.agent.close();
@@ -817,6 +911,14 @@ export class DshMembers {
817
911
  }
818
912
  }
819
913
 
914
+ /** Close queued CLI jobs too: run/markSent must not revive a process after cleanup. */
915
+ cancelCliStarts(matches) {
916
+ for (const job of this.store.openJobs()) {
917
+ if (job.state !== 'pending' || this.config.get(job.member_id)?.cli === undefined || !matches(job.member_id, job.room_id)) continue;
918
+ this.store.finishJob(job.id, 'interrupted');
919
+ }
920
+ }
921
+
820
922
  /** Watch for the consuming turn, admit the input, and post the outcome. */
821
923
  async admitAndSettle(job, member, agent, input, seqBefore) {
822
924
  const sessionId = agent.session.id;
@@ -1,8 +1,11 @@
1
- import { mkdirSync } from 'node:fs';
2
- import { dirname } from 'node:path';
1
+ import { accessSync, chmodSync, constants, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
3
  import { DatabaseSync } from 'node:sqlite';
4
4
 
5
5
  export const LOCK_FILE = 'collab.lock';
6
+ // #216: the holder's identity, so the DSH that is refused can say who holds the lock
7
+ // instead of calling a busy service "not configured".
8
+ export const OWNER_FILE = 'collab.owner.json';
6
9
 
7
10
  /**
8
11
  * #1019: one DSH process per collab data directory. Two hosts on the same collab.sqlite3 both run
@@ -44,3 +47,133 @@ export function lockBusyMessage(path) {
44
47
  return `conest-collab: 频道插件没有启动——这台电脑上已经有另一个 DSH 在用同一份频道数据(${path})。`
45
48
  + '两个同时跑会让提醒、派活和消息各处理两遍。请用已经打开的那个 DSH;要换就先关掉它再打开。';
46
49
  }
50
+
51
+ /**
52
+ * #216: the instance that holds the lock records who it is next to it, so the DSH that is
53
+ * refused can name the holder instead of telling the user to go and configure collab.yml.
54
+ * Written after the lock is held (so the record always describes the current holder) and
55
+ * removed on release; a record left behind by a crash is harmless because the reader also
56
+ * checks the lock itself.
57
+ * @returns the record written.
58
+ */
59
+ export function writeLockOwner(path, info) {
60
+ const file = join(dirname(path), OWNER_FILE);
61
+ mkdirSync(dirname(file), { recursive: true });
62
+ const record = { ...info, lock: path, writtenAt: info.writtenAt ?? Date.now() };
63
+ // 来源:沿用 pairing.mjs saveJSON 的私有凭据权限;收紧旧诊断文件后再写连接口令。
64
+ try { chmodSync(file, 0o600); } catch (error) { if (error.code !== 'ENOENT') throw error; }
65
+ writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
66
+ return record;
67
+ }
68
+
69
+ /**
70
+ * #216: the holder record for the data directory of `path`.
71
+ * @returns the record, or undefined when no holder wrote one (an older install, or a
72
+ * crashed holder whose lock the kernel already dropped).
73
+ */
74
+ export function readLockOwner(path) {
75
+ try {
76
+ const parsed = JSON.parse(readFileSync(join(dirname(path), OWNER_FILE), 'utf8'));
77
+ return parsed !== null && typeof parsed === 'object' ? parsed : undefined;
78
+ } catch {
79
+ // Missing or half-written: the caller falls back to the plain refusal.
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /** #216: remove `pid`'s record; another live holder's record is left alone. */
85
+ export function clearLockOwner(path, pid = process.pid) {
86
+ const record = readLockOwner(path);
87
+ if (record !== undefined && record.pid !== undefined && record.pid !== pid) return;
88
+ try { unlinkSync(join(dirname(path), OWNER_FILE)); } catch { /* already gone */ }
89
+ }
90
+
91
+ /**
92
+ * #216/#227: take the lock and publish who holds it, as one step.
93
+ *
94
+ * Writing the record can fail (a directory where the file should be, a full or read-only data
95
+ * directory). The lock must not survive that failure: the plugin throws, the DSH keeps running
96
+ * with the error logged, and a lock nobody will ever release welds the data directory shut for
97
+ * every later DSH — the user sees "another DSH is using this" with no DSH to quit. So a failed
98
+ * write releases the lock before the error travels on.
99
+ *
100
+ * @returns the held lock with its record written.
101
+ * @throws the write error, after releasing the lock.
102
+ */
103
+ export function acquireLockWithOwner(path, info) {
104
+ const lock = InstanceLock.acquire(path);
105
+ if (lock === undefined) return undefined;
106
+ try {
107
+ writeLockOwner(path, info);
108
+ } catch (error) {
109
+ lock.release();
110
+ throw error;
111
+ }
112
+ return lock;
113
+ }
114
+
115
+ /** #216: is `pid` a live process on this machine? EPERM means it exists but is not ours. */
116
+ function alive(pid) {
117
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
118
+ try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
119
+ }
120
+
121
+ /**
122
+ * #227: does anyone hold the lock at `path`? Answered by reading it, never by taking it.
123
+ *
124
+ * The probe used to be `InstanceLock.acquire` + release, so it held the exclusive lock for the
125
+ * instant between the two: a page poll landing in that instant made a DSH starting at the same
126
+ * moment refuse to start (实测约 95 us/次,另一进程 120 次启动被拒 0 次但这是运气), and on a free
127
+ * directory it created the lock file. A read-only connection takes no lock and creates no file:
128
+ * a holder's `BEGIN EXCLUSIVE` makes the read fail at once with SQLITE_BUSY — the same signal
129
+ * without the contention (实测约 32 us/次).
130
+ *
131
+ * @returns 'held' when someone holds the lock, 'free' when nobody does, 'unknown' when the file
132
+ * cannot be read for a reason other than "locked" (permissions, I/O).
133
+ */
134
+ function probeLock(path) {
135
+ let db;
136
+ try {
137
+ db = new DatabaseSync(path, { readOnly: true });
138
+ // 来源:沿用 InstanceLock.acquire 的 0 —— 探测不能等,等就等于页面轮询把状态请求拖住。
139
+ db.exec('PRAGMA busy_timeout = 0;');
140
+ db.prepare('SELECT count(*) FROM sqlite_master').get();
141
+ return 'free';
142
+ } catch (error) {
143
+ // 来源:SQLite 结果码——5 BUSY / 6 LOCKED 是别人的排他事务。
144
+ if (error?.errcode === 5 || error?.errcode === 6 || /\blocked\b|\bbusy\b/i.test(String(error?.message))) return 'held';
145
+ // 14 CANTOPEN 有歧义:文件不在(没人持锁)和数据目录不可读(读不出状态)都会报它。
146
+ // 来源:实测——目录 000 时 existsSync 也回 false,只有 access 的 EACCES/ENOENT 能分开。
147
+ try { accessSync(path, constants.R_OK); } catch (denied) { return denied?.code === 'ENOENT' ? 'free' : 'unknown'; }
148
+ return 'unknown';
149
+ } finally {
150
+ try { db?.close(); } catch { /* nothing to close */ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * #216: what a DSH that lost the lock tells its own page about that lock.
156
+ * - the lock is free (the holder quit): `free`, so the page asks for a restart instead of
157
+ * repeating a holder that already let go;
158
+ * - the lock is held and a live holder recorded itself: who holds it;
159
+ * - the lock is held, or cannot be read, without a trustworthy record: busy, holder unknown —
160
+ * the page must still not call it "not configured".
161
+ *
162
+ * The record alone never decides. Only `alive(pid)` plus a held lock makes it the holder: a dead
163
+ * pid's record outlives its holder, and a crash between `release` and `clearLockOwner` leaves a
164
+ * record naming a process that holds nothing (#227 2026-11-06 19:40,notes/utils/227-lockprobe/e.mjs).
165
+ */
166
+ export function lockStatus(path) {
167
+ const record = readLockOwner(path);
168
+ // #216: the data directory and the lock path travel with every answer, so a page told
169
+ // "busy, holder unknown" can still say which directory to look at and which file to check
170
+ // (`lsof <lock>`) — and does not have to rebuild a path itself, which breaks on Windows.
171
+ const where = { dataDir: dirname(path), lock: path };
172
+ if (probeLock(path) === 'free') return { busy: false, free: true, ...where };
173
+ // #227: still held (or unreadable) — name the holder only when the record and the lock agree.
174
+ if (record !== undefined && alive(record.pid)) {
175
+ const { local, ...owner } = record;
176
+ return { busy: true, owner, ...where };
177
+ }
178
+ return { busy: true, owner: null, ...where };
179
+ }
@@ -0,0 +1,106 @@
1
+ // #216: later DSH windows use the existing channel host; only that host opens the store.
2
+ import { request as httpRequest } from 'node:http';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { readLockOwner, lockStatus } from './instance-lock.mjs';
5
+ import { isLoopback } from './pairing.mjs';
6
+
7
+ /** Private discovery material, kept beside the lock and omitted from the diagnostic API. */
8
+ export function localConnection(webServer) {
9
+ const url = new URL(webServer.getOrigin());
10
+ if (url.protocol !== 'http:' || !isLoopback(url.hostname.replace(/^\[|\]$/g, ''))) return undefined;
11
+ return { origin: url.origin, token: randomUUID() };
12
+ }
13
+
14
+ /** A forwarder proves it can read the holder's private discovery file. */
15
+ export function isLocalOwner(req, connection) {
16
+ return connection !== undefined && isLoopback(req.socket.remoteAddress)
17
+ && req.headers['x-collab-local'] === connection.token;
18
+ }
19
+
20
+ /** Same-origin HTTP/SSE bridge, using pairing.mjs remoteProxyRoute's streaming lifecycle. */
21
+ export function forwardLocal(req, res, lockPath, reject) {
22
+ const denied = reject(req);
23
+ if (denied !== undefined) return reply(res, denied, { error: '请先登录本页 DSH' });
24
+ const status = lockStatus(lockPath);
25
+ const record = readLockOwner(lockPath);
26
+ const connection = status.busy && status.owner?.pid !== undefined && status.owner.pid === record?.pid ? record?.local : undefined;
27
+ if (connection === undefined) return reply(res, 404, { error: '频道宿主无法自动连接,请查看占用提示' });
28
+ let url;
29
+ try {
30
+ url = new URL(connection.origin);
31
+ if (url.protocol !== 'http:' || !isLoopback(url.hostname.replace(/^\[|\]$/g, '')) || typeof connection.token !== 'string') throw Error('invalid local host');
32
+ } catch {
33
+ return reply(res, 404, { error: '频道宿主没有留下可用的本机连接信息' });
34
+ }
35
+ const headers = { 'x-collab-local': connection.token };
36
+ // 来源:沿用 pairing.mjs PASS_REQUEST; neither host's browser credentials cross this bridge.
37
+ for (const h of ['accept', 'content-type', 'content-length', 'last-event-id', 'range', 'if-none-match', 'if-modified-since']) {
38
+ if (req.headers[h] !== undefined) headers[h] = req.headers[h];
39
+ }
40
+ return new Promise(resolve => {
41
+ const up = httpRequest(url, { path: req.url, method: req.method, headers }, incoming => {
42
+ const out = {};
43
+ // 来源:沿用 pairing.mjs PASS_RESPONSE; no upstream cookie reaches the browser.
44
+ for (const h of ['content-type', 'cache-control', 'etag', 'last-modified', 'content-disposition', 'content-range', 'accept-ranges', 'location']) {
45
+ if (incoming.headers[h] !== undefined) out[h] = incoming.headers[h];
46
+ }
47
+ res.writeHead(incoming.statusCode, out);
48
+ res.flushHeaders?.();
49
+ incoming.pipe(res);
50
+ incoming.on('end', resolve);
51
+ incoming.on('error', () => { res.destroy(); resolve(); });
52
+ });
53
+ up.on('error', () => {
54
+ if (!res.headersSent) reply(res, 404, { error: '频道宿主已断开,请查看占用提示' });
55
+ else res.destroy();
56
+ resolve();
57
+ });
58
+ req.on('aborted', () => up.destroy());
59
+ req.on('error', () => up.destroy());
60
+ res.on('close', () => { if (!res.writableEnded) up.destroy(); });
61
+ req.pipe(up);
62
+ });
63
+ }
64
+
65
+ function reply(res, status, value) {
66
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
67
+ res.end(JSON.stringify(value));
68
+ }
69
+
70
+ /** #227: where a later DSH reports which process holds the channel data. */
71
+ export const BUSY_PATH = '/collab/api/lock';
72
+
73
+ /**
74
+ * #597 G26: a later ticket only opens workspaces (and later). Sent anywhere else, say where to go
75
+ * instead of a bare 401, so a member does not give up; the route's own auth is unchanged.
76
+ */
77
+ export const TICKET_WRONG_ROUTE = '这个接口不认 X-Later-Ticket 票据。建工作区请用 POST /collab/api/rooms/{id}/workspaces(同一个票据头,JSON: title, owner, reason),任务和十分钟看门会自动开,不用再调 /tasks。';
78
+
79
+ /** The 401/403 split every scoped route uses when the caller is not the owner. */
80
+ export function refuse(req, res) {
81
+ if (req.headers?.['x-later-ticket'] !== undefined) return reply(res, 403, { error: TICKET_WRONG_ROUTE });
82
+ return reply(res, 401, { error: 'unauthorized' });
83
+ }
84
+
85
+ /**
86
+ * #227: the two routes a second DSH serves when another process holds the channel data.
87
+ * It is a client of that host, never a second writer, so it publishes the holder's status and
88
+ * forwards the channel API instead of opening the store.
89
+ *
90
+ * Extracted from `apply` so this branch can be tested without loading the plugin (审稿 F2:
91
+ * `plugin.mjs` needs the DSH runtime to import, and this branch had no unit test at all).
92
+ *
93
+ * @param ctx - the plugin context: `webServer.register`, `effect`, `connection.requestRejection`.
94
+ * @param lockPath - the lock file the holder took; both routes answer from it.
95
+ */
96
+ export function mountSharedHost(ctx, lockPath) {
97
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: BUSY_PATH,
98
+ handler: (req, res) => {
99
+ if (ctx.connection.requestRejection(req) !== undefined) return refuse(req, res);
100
+ return reply(res, 200, lockStatus(lockPath));
101
+ },
102
+ }), 'conest-collab: holder status');
103
+ ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/collab',
104
+ handler: (req, res) => forwardLocal(req, res, lockPath, req => ctx.connection.requestRejection(req)),
105
+ }), 'conest-collab: shared local host');
106
+ }