@yadsh/dsh-qa-surface 0.8.0 → 0.9.0

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.
Files changed (157) hide show
  1. package/README.md +113 -3
  2. package/compatibility.json +5 -0
  3. package/lib/access/capability-catalog.js +32 -3
  4. package/lib/access/capability-catalog.js.map +1 -1
  5. package/lib/access/service.js +45 -17
  6. package/lib/access/service.js.map +1 -1
  7. package/lib/accounts/errors.js.map +1 -1
  8. package/lib/accounts/store.js +14 -0
  9. package/lib/accounts/store.js.map +1 -1
  10. package/lib/admin/permissions.js +4 -0
  11. package/lib/admin/permissions.js.map +1 -1
  12. package/lib/admin/quality-store.js +71 -0
  13. package/lib/admin/quality-store.js.map +1 -1
  14. package/lib/admin/service.js +106 -1
  15. package/lib/admin/service.js.map +1 -1
  16. package/lib/admin/session-log.js +66 -5
  17. package/lib/admin/session-log.js.map +1 -1
  18. package/lib/client/QaSessionController.js +359 -37
  19. package/lib/client/QaSessionController.js.map +1 -1
  20. package/lib/client/QaSurface.js +26 -4
  21. package/lib/client/QaSurface.js.map +1 -1
  22. package/lib/client/QaTranscriptAdapter.js +36 -0
  23. package/lib/client/QaTranscriptAdapter.js.map +1 -1
  24. package/lib/client/admin/QaAdmin.js +9 -1
  25. package/lib/client/admin/QaAdmin.js.map +1 -1
  26. package/lib/client/admin/pages/Conversations.js +33 -7
  27. package/lib/client/admin/pages/Conversations.js.map +1 -1
  28. package/lib/client/admin/shared.js +9 -0
  29. package/lib/client/admin/shared.js.map +1 -1
  30. package/lib/client/admin/styles.js +2 -0
  31. package/lib/client/admin/styles.js.map +1 -1
  32. package/lib/client/audit/QaAuditDialog.js +74 -0
  33. package/lib/client/audit/QaAuditDialog.js.map +1 -0
  34. package/lib/client/audit/QaAuditRowBadge.js +17 -0
  35. package/lib/client/audit/QaAuditRowBadge.js.map +1 -0
  36. package/lib/client/audit/controller.js +39 -0
  37. package/lib/client/audit/controller.js.map +1 -0
  38. package/lib/client/audit/types.js +36 -0
  39. package/lib/client/audit/types.js.map +1 -0
  40. package/lib/client/audit/use-chat-audits.js +139 -0
  41. package/lib/client/audit/use-chat-audits.js.map +1 -0
  42. package/lib/client/components/QaChangelog.js +43 -0
  43. package/lib/client/components/QaChangelog.js.map +1 -1
  44. package/lib/client/components/QaComposer.js +63 -3
  45. package/lib/client/components/QaComposer.js.map +1 -1
  46. package/lib/client/components/QaMessage.js +16 -0
  47. package/lib/client/components/QaMessage.js.map +1 -1
  48. package/lib/client/components/QaQuestions.js +1 -1
  49. package/lib/client/components/QaQuestions.js.map +1 -1
  50. package/lib/client/components/QaSidebar.js +12 -1
  51. package/lib/client/components/QaSidebar.js.map +1 -1
  52. package/lib/client/index.js +22 -2
  53. package/lib/client/index.js.map +1 -1
  54. package/lib/client/project-session-state.js +15 -2
  55. package/lib/client/project-session-state.js.map +1 -1
  56. package/lib/client/prompt-content.js +26 -0
  57. package/lib/client/prompt-content.js.map +1 -1
  58. package/lib/client/settings/card.js +2 -2
  59. package/lib/client/settings/card.js.map +1 -1
  60. package/lib/client/settings/sections/lockdown.js +1 -6
  61. package/lib/client/settings/sections/lockdown.js.map +1 -1
  62. package/lib/client/settings/sections/slash.js +57 -0
  63. package/lib/client/settings/sections/slash.js.map +1 -0
  64. package/lib/client/settings/sections/status.js +9 -1
  65. package/lib/client/settings/sections/status.js.map +1 -1
  66. package/lib/client/settings/sections.js +1 -0
  67. package/lib/client/settings/sections.js.map +1 -1
  68. package/lib/client/slash/SlashPalette.js +48 -0
  69. package/lib/client/slash/SlashPalette.js.map +1 -0
  70. package/lib/client/slash/copy.js +54 -0
  71. package/lib/client/slash/copy.js.map +1 -0
  72. package/lib/client/slash/slash-router.js +49 -0
  73. package/lib/client/slash/slash-router.js.map +1 -0
  74. package/lib/client/slash/useSlashNavigation.js +95 -0
  75. package/lib/client/slash/useSlashNavigation.js.map +1 -0
  76. package/lib/client/styles.js +51 -1
  77. package/lib/client/styles.js.map +1 -1
  78. package/lib/client/types.js +11 -0
  79. package/lib/client/types.js.map +1 -1
  80. package/lib/client.js +4231 -551
  81. package/lib/client.js.map +1 -1
  82. package/lib/config-resolvers/basics.js +11 -2
  83. package/lib/config-resolvers/basics.js.map +1 -1
  84. package/lib/config-resolvers/defaults.js +22 -0
  85. package/lib/config-resolvers/defaults.js.map +1 -1
  86. package/lib/config-resolvers/lockdown.js +8 -2
  87. package/lib/config-resolvers/lockdown.js.map +1 -1
  88. package/lib/config-resolvers/slash-commands.js +60 -0
  89. package/lib/config-resolvers/slash-commands.js.map +1 -0
  90. package/lib/config.js +61 -2
  91. package/lib/config.js.map +1 -1
  92. package/lib/index.js +158 -1
  93. package/lib/index.js.map +1 -1
  94. package/lib/personal-skills/service.js +16 -3
  95. package/lib/personal-skills/service.js.map +1 -1
  96. package/lib/provenance/host-store.js +18 -1
  97. package/lib/provenance/host-store.js.map +1 -1
  98. package/lib/questions.js +90 -43
  99. package/lib/questions.js.map +1 -1
  100. package/lib/resolve-config.js +5 -0
  101. package/lib/resolve-config.js.map +1 -1
  102. package/lib/secure-session.js +24 -0
  103. package/lib/secure-session.js.map +1 -1
  104. package/lib/slash/catalog.js +77 -0
  105. package/lib/slash/catalog.js.map +1 -0
  106. package/lib/slash/matcher.js +100 -0
  107. package/lib/slash/matcher.js.map +1 -0
  108. package/lib/slash/parser.js +79 -0
  109. package/lib/slash/parser.js.map +1 -0
  110. package/lib/slash/policy.js +72 -0
  111. package/lib/slash/policy.js.map +1 -0
  112. package/lib/slash/remotes.js +272 -0
  113. package/lib/slash/remotes.js.map +1 -0
  114. package/lib/typert.host.js +318 -53
  115. package/lib/typert.remote-client.d.ts +7 -1
  116. package/lib/typert.remote-client.js +247 -51
  117. package/lib/types/access/capability-catalog.d.ts +28 -1
  118. package/lib/types/access/service.d.ts +33 -4
  119. package/lib/types/accounts/errors.d.ts +7 -1
  120. package/lib/types/accounts/store.d.ts +12 -0
  121. package/lib/types/admin/quality-store.d.ts +18 -0
  122. package/lib/types/admin/service.d.ts +34 -2
  123. package/lib/types/admin/session-log.d.ts +65 -1
  124. package/lib/types/client/QaSessionController.d.ts +84 -2
  125. package/lib/types/client/QaSurface.d.ts +14 -1
  126. package/lib/types/client/admin/pages/Conversations.d.ts +4 -0
  127. package/lib/types/client/audit/QaAuditDialog.d.ts +21 -0
  128. package/lib/types/client/audit/QaAuditRowBadge.d.ts +18 -0
  129. package/lib/types/client/audit/controller.d.ts +27 -0
  130. package/lib/types/client/audit/types.d.ts +72 -0
  131. package/lib/types/client/audit/use-chat-audits.d.ts +20 -0
  132. package/lib/types/client/components/QaComposer.d.ts +12 -2
  133. package/lib/types/client/components/QaQuestions.d.ts +7 -0
  134. package/lib/types/client/components/QaSidebar.d.ts +8 -0
  135. package/lib/types/client/project-session-state.d.ts +3 -1
  136. package/lib/types/client/prompt-content.d.ts +9 -1
  137. package/lib/types/client/settings/sections/slash.d.ts +16 -0
  138. package/lib/types/client/settings/sections.d.ts +1 -0
  139. package/lib/types/client/slash/SlashPalette.d.ts +24 -0
  140. package/lib/types/client/slash/copy.d.ts +30 -0
  141. package/lib/types/client/slash/slash-router.d.ts +67 -0
  142. package/lib/types/client/slash/useSlashNavigation.d.ts +47 -0
  143. package/lib/types/client/styles.d.ts +9 -0
  144. package/lib/types/client/types.d.ts +16 -1
  145. package/lib/types/config-resolvers/slash-commands.d.ts +20 -0
  146. package/lib/types/index.d.ts +78 -1
  147. package/lib/types/personal-skills/service.d.ts +5 -0
  148. package/lib/types/provenance/host-store.d.ts +13 -0
  149. package/lib/types/questions.d.ts +8 -0
  150. package/lib/types/slash/catalog.d.ts +56 -0
  151. package/lib/types/slash/matcher.d.ts +51 -0
  152. package/lib/types/slash/parser.d.ts +46 -0
  153. package/lib/types/slash/policy.d.ts +40 -0
  154. package/lib/types/slash/remotes.d.ts +35 -0
  155. package/lib/types/types.d.ts +200 -5
  156. package/package.json +7 -3
  157. package/scripts/attach-workspace-sessions.mjs +472 -0
@@ -0,0 +1,472 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Attach existing DSH sessions to a registered Workspace.
5
+ *
6
+ * DSH grants Workspace membership exactly once, and only at creation: the
7
+ * `workspaceId` branch of `session.create` calls `Workspace.attachSession`,
8
+ * which refuses a session whose canonical cwd is not exactly the Workspace
9
+ * path (packages/workspace/workspace/src/entity.ts), and the browser builds
10
+ * the workspace tree from `workspace.sessionIds` alone
11
+ * (packages/client/ui-workspace/src/client/tree.ts). A session created through
12
+ * the `cwd` pin - or through the `workspaceId` pin with the same directory
13
+ * spelled differently - therefore never joins a workspace and stays in the
14
+ * host UI's "Ungrouped" bucket forever: the contract has no attach RPC,
15
+ * `insertSessionBefore` only reorders existing members, and dragging a session
16
+ * never leaves its group.
17
+ *
18
+ * This command repairs that state offline, on the registry file the host
19
+ * writes itself (`$DSH_HOME/storages/workspace.json`). It adopts every session
20
+ * whose canonical cwd equals a registered Workspace path, and only those.
21
+ * Sessions below a Workspace path - a QA per-user `.qa-users/<uuid>` root, for
22
+ * instance - are reported and refused: the host filters membership by that
23
+ * same comparison on every read, so it would drop them again on the next
24
+ * start.
25
+ */
26
+
27
+ import {
28
+ chmodSync,
29
+ closeSync,
30
+ copyFileSync,
31
+ constants as fsConstants,
32
+ existsSync,
33
+ lstatSync,
34
+ openSync,
35
+ readFileSync,
36
+ readSync,
37
+ readdirSync,
38
+ realpathSync,
39
+ renameSync,
40
+ statSync,
41
+ writeFileSync,
42
+ } from "node:fs";
43
+ import os from "node:os";
44
+ import path from "node:path";
45
+ import { pathToFileURL } from "node:url";
46
+ import { zstdDecompressSync } from "node:zlib";
47
+
48
+ /** Storage file holding every Workspace record and its membership. */
49
+ export const REGISTRY_FILE = path.join("storages", "workspace.json");
50
+
51
+ /** Session logs live in `<home>/sessions/<escaped cwd>/<session id>/`. */
52
+ export const SESSIONS_DIRECTORY = "sessions";
53
+
54
+ const HEADER_BYTES = 64 * 1024;
55
+
56
+ function isDirectory(target) {
57
+ try {
58
+ return lstatSync(target).isDirectory();
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Canonical path of an existing directory, for the byte-exact comparison the
66
+ * host performs. A path that does not resolve has no membership anywhere.
67
+ * @param target - absolute directory path.
68
+ * @returns canonical path, or undefined when it cannot be resolved.
69
+ */
70
+ export function canonical(target) {
71
+ if (typeof target !== "string" || target === "") return undefined;
72
+ try {
73
+ const resolved = realpathSync.native(target);
74
+ return isDirectory(resolved) ? resolved : undefined;
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ }
79
+
80
+ /** Windows paths differ in case between spellings of one directory. */
81
+ function samePath(left, right) {
82
+ return process.platform === "win32"
83
+ ? left.toLowerCase() === right.toLowerCase()
84
+ : left === right;
85
+ }
86
+
87
+ /** A path strictly below another one, for the refusal report. */
88
+ function isBelow(target, root) {
89
+ const relative = path.relative(root, target);
90
+ return (
91
+ relative !== "" &&
92
+ !relative.startsWith(`..${path.sep}`) &&
93
+ relative !== ".." &&
94
+ !path.isAbsolute(relative)
95
+ );
96
+ }
97
+
98
+ /** The session log inside one session directory, if it carries one. */
99
+ function sessionLog(directory) {
100
+ for (const entry of readdirSync(directory)) {
101
+ if (entry.endsWith(".jsonl.zstd") || entry.endsWith(".jsonl")) {
102
+ return path.join(directory, entry);
103
+ }
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ function firstBytes(file) {
109
+ const handle = openSync(file, "r");
110
+ try {
111
+ const buffer = Buffer.alloc(HEADER_BYTES);
112
+ return buffer.subarray(0, readSync(handle, buffer, 0, HEADER_BYTES, 0));
113
+ } finally {
114
+ closeSync(handle);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Read the header line of one session log. The header is the first JSON line
120
+ * of the first Zstandard frame; a plaintext log keeps the same line first.
121
+ * @param file - session log path.
122
+ * @returns parsed header, or undefined when the file carries none.
123
+ */
124
+ export function readSessionHeader(file) {
125
+ let text;
126
+ try {
127
+ text =
128
+ file.endsWith(".zstd") === true
129
+ ? zstdDecompressSync(readFileSync(file)).toString("utf8")
130
+ : firstBytes(file).toString("utf8");
131
+ } catch {
132
+ return undefined;
133
+ }
134
+ const end = text.indexOf("\n");
135
+ const line = end === -1 ? text : text.slice(0, end);
136
+ try {
137
+ const header = JSON.parse(line);
138
+ return header?.type === "session" ? header : undefined;
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Every session the store knows, with the facts membership depends on.
146
+ * @param home - DSH home directory.
147
+ * @returns session records in directory order.
148
+ */
149
+ export function collectSessions(home) {
150
+ const root = path.join(home, SESSIONS_DIRECTORY);
151
+ const sessions = [];
152
+ if (!isDirectory(root)) return sessions;
153
+ for (const projectDir of readdirSync(root)) {
154
+ const projectPath = path.join(root, projectDir);
155
+ if (!isDirectory(projectPath)) continue;
156
+ for (const sessionDir of readdirSync(projectPath)) {
157
+ const sessionPath = path.join(projectPath, sessionDir);
158
+ if (!isDirectory(sessionPath)) continue;
159
+ const log = sessionLog(sessionPath);
160
+ if (log === undefined) continue;
161
+ const header = readSessionHeader(log);
162
+ if (header === undefined || typeof header.id !== "string") continue;
163
+ sessions.push({
164
+ id: header.id,
165
+ cwd: typeof header.cwd === "string" ? header.cwd : undefined,
166
+ subagent: header.parentSession !== undefined,
167
+ createdAt: typeof header.createdAt === "number" ? header.createdAt : 0,
168
+ });
169
+ }
170
+ }
171
+ return sessions;
172
+ }
173
+
174
+ /**
175
+ * Read the Workspace registry.
176
+ * @param file - registry path.
177
+ * @returns parsed registry.
178
+ */
179
+ export function readRegistry(file) {
180
+ let parsed;
181
+ try {
182
+ parsed = JSON.parse(readFileSync(file, "utf8"));
183
+ } catch (error) {
184
+ throw new Error(
185
+ `cannot read the workspace registry ${file}: ${error instanceof Error ? error.message : String(error)}`,
186
+ { cause: error },
187
+ );
188
+ }
189
+ if (parsed?.tables?.workspaces === undefined) {
190
+ throw new Error(`${file}: no workspace table; not a DSH registry`);
191
+ }
192
+ return parsed;
193
+ }
194
+
195
+ /**
196
+ * Decide what an offline repair would change, without touching anything.
197
+ * @param options - home directory, optional single workspace, subagent switch.
198
+ * @returns one entry per workspace plus the sessions no repair can place.
199
+ */
200
+ export function planAttachments({ home, workspace, includeSubagents = false }) {
201
+ const registryPath = path.join(home, REGISTRY_FILE);
202
+ const registry = readRegistry(registryPath);
203
+ const archived = new Set(registry.global?.archivedSessionIds ?? []);
204
+ const sessions = collectSessions(home);
205
+ const claimed = new Set(
206
+ Object.values(registry.tables.workspaces).flatMap(
207
+ (record) => record.sessionIds,
208
+ ),
209
+ );
210
+ const requested =
211
+ workspace === undefined ? undefined : workspace.trim().toLowerCase();
212
+
213
+ const workspaces = [];
214
+ for (const [id, record] of Object.entries(registry.tables.workspaces)) {
215
+ if (
216
+ requested !== undefined &&
217
+ requested !== id.toLowerCase() &&
218
+ requested !== String(record.path).toLowerCase()
219
+ ) {
220
+ continue;
221
+ }
222
+ const root = canonical(record.path);
223
+ const entry = {
224
+ id,
225
+ path: record.path,
226
+ title: record.title,
227
+ root,
228
+ attachable: [],
229
+ below: [],
230
+ unresolved: [],
231
+ archived: 0,
232
+ elsewhere: 0,
233
+ refused: 0,
234
+ members: record.sessionIds.length,
235
+ };
236
+ workspaces.push(entry);
237
+ if (root === undefined) continue;
238
+ for (const session of sessions) {
239
+ if (record.sessionIds.includes(session.id)) continue;
240
+ if (archived.has(session.id)) {
241
+ entry.archived += 1;
242
+ continue;
243
+ }
244
+ if (!includeSubagents && session.subagent) {
245
+ entry.refused += 1;
246
+ continue;
247
+ }
248
+ const cwd = canonical(session.cwd);
249
+ if (cwd === undefined) {
250
+ entry.unresolved.push(session);
251
+ continue;
252
+ }
253
+ if (samePath(cwd, root)) {
254
+ entry.attachable.push({ ...session, cwd });
255
+ continue;
256
+ }
257
+ if (isBelow(cwd, root)) {
258
+ entry.below.push({ ...session, cwd });
259
+ continue;
260
+ }
261
+ entry.elsewhere += 1;
262
+ }
263
+ entry.attachable.sort((left, right) => right.createdAt - left.createdAt);
264
+ }
265
+
266
+ return {
267
+ registryPath,
268
+ workspaces,
269
+ sessions: sessions.length,
270
+ claimed: claimed.size,
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Publish one plan. Only the membership list and `updatedAt` change, exactly
276
+ * as `Workspace.attachSession` writes them; every other field of the registry
277
+ * is preserved.
278
+ * @param plan - result of {@link planAttachments}.
279
+ * @param options - `write` false keeps the registry untouched.
280
+ * @returns what was (or would be) written.
281
+ */
282
+ export function applyPlan(plan, { write = false } = {}) {
283
+ const before = statSync(plan.registryPath);
284
+ const registry = readRegistry(plan.registryPath);
285
+ const adopted = [];
286
+ for (const entry of plan.workspaces) {
287
+ if (entry.attachable.length === 0) continue;
288
+ const record = registry.tables.workspaces[entry.id];
289
+ if (record === undefined) continue;
290
+ // Newest first, the order `attachSession` prepends into.
291
+ const ids = entry.attachable.map((session) => session.id);
292
+ adopted.push({ id: entry.id, path: record.path, ids });
293
+ record.sessionIds = [
294
+ ...ids,
295
+ ...record.sessionIds.filter((id) => !ids.includes(id)),
296
+ ];
297
+ record.updatedAt = new Date().toISOString();
298
+ }
299
+ if (adopted.length === 0 || !write) {
300
+ return { adopted, written: false, backup: undefined };
301
+ }
302
+
303
+ const now = statSync(plan.registryPath);
304
+ if (now.size !== before.size || now.mtimeMs !== before.mtimeMs) {
305
+ throw new Error(
306
+ `${plan.registryPath}: changed while being inspected; stop DSH and retry`,
307
+ );
308
+ }
309
+ const backup = `${plan.registryPath}.pre-workspace-attach.bak`;
310
+ copyFileSync(plan.registryPath, backup, fsConstants.COPYFILE_EXCL);
311
+ const temp = `${plan.registryPath}.${process.pid}.attach.tmp`;
312
+ writeFileSync(temp, `${JSON.stringify(registry, null, 2)}\n`, {
313
+ mode: before.mode,
314
+ });
315
+ chmodSync(temp, before.mode);
316
+ renameSync(temp, plan.registryPath);
317
+ // Re-read before reporting success: a registry the host cannot parse would
318
+ // lose the operator's whole workspace list, not just this repair.
319
+ const written = readRegistry(plan.registryPath);
320
+ for (const entry of adopted) {
321
+ const record = written.tables.workspaces[entry.id];
322
+ if (
323
+ record === undefined ||
324
+ !entry.ids.every((id) => record.sessionIds.includes(id))
325
+ ) {
326
+ throw new Error(`${plan.registryPath}: ${entry.id} lost the adopted ids`);
327
+ }
328
+ }
329
+ return { adopted, written: true, backup };
330
+ }
331
+
332
+ function usage() {
333
+ return `Usage: qa-attach-sessions [--write] [--home <dir>] [--workspace <uuid-or-path>] [--include-subagents]
334
+
335
+ Dry-run is the default. Adopts every session whose stored cwd IS a registered
336
+ Workspace path (matched after realpath), which is the only membership DSH
337
+ itself would accept. Sessions below a Workspace path - a QA per-user
338
+ .qa-users/<uuid> root, for instance - cannot be adopted: the host compares the
339
+ cwd with the workspace path again on every read and would drop them.
340
+
341
+ Defaults: --home $DSH_HOME (or ~/.dsh), every registered Workspace, subagent
342
+ sessions left alone. Stop DSH before using --write; the registry is backed up
343
+ to *.pre-workspace-attach.bak first.`;
344
+ }
345
+
346
+ /**
347
+ * @param argv - command line arguments without the interpreter and script.
348
+ * @returns process exit code.
349
+ */
350
+ export function main(argv = process.argv.slice(2)) {
351
+ if (argv.includes("--help") || argv.includes("-h")) {
352
+ console.log(usage());
353
+ return 0;
354
+ }
355
+ const value = (name) => {
356
+ const index = argv.indexOf(`--${name}`);
357
+ if (index !== -1) {
358
+ const next = argv[index + 1];
359
+ if (next === undefined || next.startsWith("--")) {
360
+ throw new Error(`--${name} needs a value`);
361
+ }
362
+ return next;
363
+ }
364
+ const inline = argv.find((arg) => arg.startsWith(`--${name}=`));
365
+ return inline?.slice(name.length + 3);
366
+ };
367
+ const known = new Set([
368
+ "--write",
369
+ "--help",
370
+ "-h",
371
+ "--include-subagents",
372
+ "--home",
373
+ "--workspace",
374
+ ]);
375
+ for (const arg of argv) {
376
+ const name =
377
+ arg.startsWith("--") && arg.includes("=")
378
+ ? arg.slice(0, arg.indexOf("="))
379
+ : arg;
380
+ if (name.startsWith("-") && !known.has(name)) {
381
+ throw new Error(`unknown option: ${name}`);
382
+ }
383
+ }
384
+
385
+ const write = argv.includes("--write");
386
+ const home =
387
+ value("home") ??
388
+ process.env.DSH_HOME?.trim() ??
389
+ path.join(os.homedir(), ".dsh");
390
+ if (!existsSync(path.join(home, REGISTRY_FILE))) {
391
+ throw new Error(`${path.join(home, REGISTRY_FILE)} does not exist`);
392
+ }
393
+ if (write) console.error("write mode: DSH must be stopped");
394
+
395
+ const plan = planAttachments({
396
+ home,
397
+ workspace: value("workspace"),
398
+ includeSubagents: argv.includes("--include-subagents"),
399
+ });
400
+ if (plan.workspaces.length === 0) {
401
+ throw new Error(
402
+ `no registered workspace matched ${String(value("workspace"))}`,
403
+ );
404
+ }
405
+
406
+ let attachable = 0;
407
+ for (const entry of plan.workspaces) {
408
+ if (entry.root === undefined) {
409
+ console.log(
410
+ `workspace ${entry.id} (${entry.path}): path does not resolve; skipped`,
411
+ );
412
+ continue;
413
+ }
414
+ attachable += entry.attachable.length;
415
+ console.log(
416
+ `workspace ${entry.id} (${entry.path}, "${entry.title}"): ${entry.members} member(s)`,
417
+ );
418
+ for (const session of entry.attachable) {
419
+ console.log(
420
+ ` adopt: ${session.id} <- ${session.cwd}${session.subagent ? " (subagent)" : ""}`,
421
+ );
422
+ }
423
+ if (entry.below.length > 0) {
424
+ console.log(
425
+ ` refused ${entry.below.length} session(s) below the workspace path`,
426
+ );
427
+ console.log(
428
+ ` (DSH membership is exact-path; e.g. ${entry.below[0].cwd})`,
429
+ );
430
+ }
431
+ if (entry.unresolved.length > 0) {
432
+ console.log(
433
+ ` skipped ${entry.unresolved.length} session(s) whose cwd does not resolve now`,
434
+ );
435
+ }
436
+ if (entry.archived > 0) {
437
+ console.log(` skipped ${entry.archived} archived session(s)`);
438
+ }
439
+ if (entry.refused > 0) {
440
+ console.log(
441
+ ` skipped ${entry.refused} subagent session(s); --include-subagents adopts them`,
442
+ );
443
+ }
444
+ }
445
+
446
+ const applied = applyPlan(plan, { write });
447
+ for (const entry of applied.adopted) {
448
+ console.log(
449
+ `${applied.written ? "attached" : "would attach"} ${entry.ids.length} session(s) to ${entry.path}`,
450
+ );
451
+ if (applied.backup !== undefined) console.log(`backup: ${applied.backup}`);
452
+ }
453
+ console.log(
454
+ `${write ? "attached" : "dry run"}: ${attachable} session(s) in ${plan.sessions} stored`,
455
+ );
456
+ if (!write && attachable > 0) {
457
+ console.log("run again with --write to publish the repair");
458
+ }
459
+ return 0;
460
+ }
461
+
462
+ if (
463
+ process.argv[1] !== undefined &&
464
+ pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url
465
+ ) {
466
+ try {
467
+ process.exitCode = main();
468
+ } catch (error) {
469
+ console.error(error instanceof Error ? error.message : String(error));
470
+ process.exitCode = 1;
471
+ }
472
+ }