dsh-plugin-standalone-session 0.0.0-stage → 0.1.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.
package/lib/index.js ADDED
@@ -0,0 +1,545 @@
1
+ // src/index.ts
2
+ import { homedir } from "node:os";
3
+ import { join as join2 } from "node:path";
4
+
5
+ // src/shared/standalone.ts
6
+ var STANDALONE_DIR_NAME = "dsh-standalone-sessions";
7
+ var SESSION_ID_PREFIX = "session-";
8
+ var REGISTRY_VERSION = 1;
9
+ var REGISTRY_FILE_NAME = "registry.json";
10
+ function emptyRegistry() {
11
+ return { version: REGISTRY_VERSION, sessions: {} };
12
+ }
13
+ function pathSegments(path) {
14
+ return path.split(/[\\/]+/).filter((segment) => segment !== "");
15
+ }
16
+ function isStandaloneCwd(cwd) {
17
+ if (typeof cwd !== "string" || cwd.trim() === "") return false;
18
+ const segments = pathSegments(cwd);
19
+ for (let index = 0; index < segments.length - 1; index += 1) {
20
+ if (segments[index]?.toLowerCase() === STANDALONE_DIR_NAME) return true;
21
+ }
22
+ return false;
23
+ }
24
+ function standaloneSessionIdFromCwd(cwd) {
25
+ if (!isStandaloneCwd(cwd)) return void 0;
26
+ const segments = pathSegments(cwd ?? "");
27
+ const last = segments.at(-1);
28
+ return last === void 0 || last === "" ? void 0 : last;
29
+ }
30
+ function assertSessionId(sessionId) {
31
+ if (typeof sessionId !== "string" || sessionId === "" || sessionId.length > 200) {
32
+ throw new Error("invalid standalone session id");
33
+ }
34
+ if (!sessionId.startsWith(SESSION_ID_PREFIX)) {
35
+ throw new Error(`standalone session id must start with "${SESSION_ID_PREFIX}"`);
36
+ }
37
+ if (sessionId.includes("/") || sessionId.includes("\\") || sessionId === "." || sessionId === "..") {
38
+ throw new Error("standalone session id must not contain a path separator");
39
+ }
40
+ return sessionId;
41
+ }
42
+ function cloneSessions(sessions) {
43
+ const copy = {};
44
+ for (const [id, entry] of Object.entries(sessions)) copy[id] = entry;
45
+ return copy;
46
+ }
47
+ function normalizeRegistry(value) {
48
+ if (typeof value !== "object" || value === null) return emptyRegistry();
49
+ const sessions = value.sessions;
50
+ if (typeof sessions !== "object" || sessions === null) return emptyRegistry();
51
+ const normalized = {};
52
+ for (const [id, raw] of Object.entries(sessions)) {
53
+ if (typeof raw !== "object" || raw === null) continue;
54
+ const candidate = raw;
55
+ let sessionId;
56
+ try {
57
+ sessionId = assertSessionId(id);
58
+ } catch {
59
+ continue;
60
+ }
61
+ if (typeof candidate.cwd !== "string" || !isStandaloneCwd(candidate.cwd)) continue;
62
+ if (standaloneSessionIdFromCwd(candidate.cwd) !== sessionId) continue;
63
+ const createdAt = typeof candidate.createdAt === "number" && Number.isFinite(candidate.createdAt) ? candidate.createdAt : Date.now();
64
+ const entry = {
65
+ sessionId,
66
+ cwd: candidate.cwd,
67
+ standalone: true,
68
+ createdAt,
69
+ hadMessage: candidate.hadMessage === true,
70
+ ...candidate.archived === true ? { archived: true } : {},
71
+ ...candidate.cleanupFailed === true ? { cleanupFailed: true } : {},
72
+ ...typeof candidate.cleanupError === "string" ? { cleanupError: candidate.cleanupError } : {},
73
+ ...typeof candidate.cleanedAt === "number" && Number.isFinite(candidate.cleanedAt) ? { cleanedAt: candidate.cleanedAt } : {}
74
+ };
75
+ normalized[sessionId] = entry;
76
+ }
77
+ return { version: REGISTRY_VERSION, sessions: normalized };
78
+ }
79
+ function upsertEntry(data, entry) {
80
+ const previous = data.sessions[entry.sessionId];
81
+ const merged = {
82
+ sessionId: entry.sessionId,
83
+ cwd: entry.cwd,
84
+ standalone: true,
85
+ createdAt: previous?.createdAt ?? entry.createdAt,
86
+ hadMessage: previous?.hadMessage === true || entry.hadMessage === true,
87
+ ...previous?.archived === true || entry.archived === true ? { archived: true } : {},
88
+ ...previous?.cleanupFailed === true ? { cleanupFailed: true } : {},
89
+ ...previous?.cleanupError !== void 0 ? { cleanupError: previous.cleanupError } : {},
90
+ ...previous?.cleanedAt !== void 0 ? { cleanedAt: previous.cleanedAt } : {}
91
+ };
92
+ if (previous !== void 0 && previous.cwd === merged.cwd && previous.hadMessage === merged.hadMessage && previous.archived === merged.archived) {
93
+ return data;
94
+ }
95
+ const sessions = cloneSessions(data.sessions);
96
+ sessions[entry.sessionId] = merged;
97
+ return { version: REGISTRY_VERSION, sessions };
98
+ }
99
+ function markHadMessage(data, sessionId) {
100
+ const previous = data.sessions[sessionId];
101
+ if (previous === void 0 || previous.hadMessage) return data;
102
+ const sessions = cloneSessions(data.sessions);
103
+ sessions[sessionId] = { ...previous, hadMessage: true };
104
+ return { version: REGISTRY_VERSION, sessions };
105
+ }
106
+ function markArchived(data, sessionId) {
107
+ const previous = data.sessions[sessionId];
108
+ if (previous === void 0 || previous.archived === true) return data;
109
+ const sessions = cloneSessions(data.sessions);
110
+ sessions[sessionId] = { ...previous, archived: true };
111
+ return { version: REGISTRY_VERSION, sessions };
112
+ }
113
+ function removeEntry(data, sessionId) {
114
+ if (data.sessions[sessionId] === void 0) return data;
115
+ const sessions = cloneSessions(data.sessions);
116
+ delete sessions[sessionId];
117
+ return { version: REGISTRY_VERSION, sessions };
118
+ }
119
+ function markCleanupFailed(data, sessionId, message) {
120
+ const previous = data.sessions[sessionId];
121
+ if (previous === void 0) return data;
122
+ const sessions = cloneSessions(data.sessions);
123
+ sessions[sessionId] = { ...previous, cleanupFailed: true, cleanupError: message };
124
+ return { version: REGISTRY_VERSION, sessions };
125
+ }
126
+ function listEntries(data) {
127
+ return Object.keys(data.sessions).sort((left, right) => left.localeCompare(right)).map((id) => data.sessions[id]).filter((entry) => entry !== void 0);
128
+ }
129
+ function cleanupPlan(entry, directoryState) {
130
+ if (!directoryState.exists) return { action: "remove-entry", reason: "missing" };
131
+ if (entry.hadMessage) return { action: "keep", reason: "used" };
132
+ if (!directoryState.empty) return { action: "keep", reason: "not-empty" };
133
+ return { action: "remove-tree", reason: "never-used" };
134
+ }
135
+
136
+ // src/host/registry.ts
137
+ import { mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
138
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
139
+ function prefixedLogger(prefix = "[standalone-session]") {
140
+ return {
141
+ info: (message) => console.log(`${prefix} ${message}`),
142
+ warn: (message) => console.warn(`${prefix} ${message}`)
143
+ };
144
+ }
145
+ var defaultDirectoryOps = {
146
+ stat: (path) => stat(path),
147
+ rm: (path, options) => rm(path, options)
148
+ };
149
+ function errorCode(error) {
150
+ if (typeof error !== "object" || error === null) return void 0;
151
+ const code = error.code;
152
+ return typeof code === "string" ? code : void 0;
153
+ }
154
+ function errorMessage(error) {
155
+ return error instanceof Error ? error.message : String(error);
156
+ }
157
+ function isOwnedSessionDirectory(entry) {
158
+ if (!isAbsolute(entry.cwd)) return false;
159
+ if (!isStandaloneCwd(entry.cwd)) return false;
160
+ if (standaloneSessionIdFromCwd(entry.cwd) !== entry.sessionId) return false;
161
+ return basename(resolve(entry.cwd)) === entry.sessionId && basename(dirname(resolve(entry.cwd))).toLowerCase() === STANDALONE_DIR_NAME;
162
+ }
163
+ async function cleanupSessionDirectory(entry, ops = defaultDirectoryOps, logger = prefixedLogger()) {
164
+ if (!isOwnedSessionDirectory(entry)) {
165
+ const error = `refusing to clean ${JSON.stringify(entry.cwd)}: not this plugin's session directory`;
166
+ logger.warn(error);
167
+ return { kind: "failed", error };
168
+ }
169
+ const target = resolve(entry.cwd);
170
+ let exists = false;
171
+ try {
172
+ const info = await ops.stat(target);
173
+ exists = info.isDirectory();
174
+ } catch (error) {
175
+ if (errorCode(error) !== "ENOENT") {
176
+ const message = `could not inspect ${target}: ${errorMessage(error)}`;
177
+ logger.warn(message);
178
+ return { kind: "failed", error: message };
179
+ }
180
+ }
181
+ let empty = true;
182
+ if (exists) {
183
+ try {
184
+ empty = (await readdir(target)).length === 0;
185
+ } catch (error) {
186
+ if (errorCode(error) !== "ENOENT") {
187
+ const message = `could not list ${target}: ${errorMessage(error)}`;
188
+ logger.warn(message);
189
+ return { kind: "failed", error: message };
190
+ }
191
+ }
192
+ }
193
+ const plan = cleanupPlan(entry, { exists, empty });
194
+ try {
195
+ switch (plan.action) {
196
+ case "remove-entry":
197
+ logger.info(`session directory already gone: ${target}`);
198
+ return { kind: "removed" };
199
+ case "remove-tree":
200
+ await ops.rm(target, { recursive: true, force: true });
201
+ logger.info(`removed standalone session directory: ${target}`);
202
+ return { kind: "removed" };
203
+ case "keep":
204
+ logger.info(`retained standalone session directory: ${target}`);
205
+ return { kind: "retained", reason: plan.reason };
206
+ default: {
207
+ const exhaustive = plan;
208
+ return exhaustive;
209
+ }
210
+ }
211
+ } catch (error) {
212
+ const message = `failed to clean ${target}: ${errorMessage(error)}`;
213
+ logger.warn(message);
214
+ return { kind: "failed", error: message };
215
+ }
216
+ }
217
+ var DurableStandaloneRegistry = class {
218
+ rootDir;
219
+ filePath;
220
+ data = emptyRegistry();
221
+ queue = Promise.resolve();
222
+ loadPromise;
223
+ loaded = false;
224
+ logger;
225
+ ops;
226
+ /**
227
+ * @param rootDir - directory holding session directories and the registry file.
228
+ * @param logger - diagnostic sink.
229
+ * @param ops - directory operations, injectable for tests.
230
+ */
231
+ constructor(rootDir, logger = prefixedLogger(), ops = defaultDirectoryOps) {
232
+ this.rootDir = resolve(rootDir);
233
+ this.filePath = join(this.rootDir, REGISTRY_FILE_NAME);
234
+ this.logger = logger;
235
+ this.ops = ops;
236
+ }
237
+ /** Load the registry once, recovering a corrupt file by moving it aside. */
238
+ load() {
239
+ this.loadPromise ??= this.loadOnce();
240
+ return this.loadPromise;
241
+ }
242
+ async loadOnce() {
243
+ await mkdir(this.rootDir, { recursive: true });
244
+ try {
245
+ const text = await readFile(this.filePath, "utf8");
246
+ this.data = normalizeRegistry(JSON.parse(text));
247
+ this.loaded = true;
248
+ return;
249
+ } catch (error) {
250
+ if (errorCode(error) !== "ENOENT") {
251
+ const backup = `${this.filePath}.corrupt-${Date.now()}`;
252
+ try {
253
+ await rename(this.filePath, backup);
254
+ this.logger.warn(`registry was unreadable; moved it to ${backup}: ${errorMessage(error)}`);
255
+ } catch (moveError) {
256
+ this.logger.warn(
257
+ `registry was unreadable and could not be moved aside: ${errorMessage(moveError)}`
258
+ );
259
+ }
260
+ }
261
+ this.data = emptyRegistry();
262
+ this.loaded = true;
263
+ }
264
+ }
265
+ /** Current immutable snapshot. */
266
+ snapshot() {
267
+ return this.data;
268
+ }
269
+ /** All records in stable id order. */
270
+ list() {
271
+ return listEntries(this.data);
272
+ }
273
+ /** One record, if present. */
274
+ get(sessionId) {
275
+ return this.data.sessions[sessionId];
276
+ }
277
+ /** Await every queued mutation. */
278
+ async flush() {
279
+ await this.queue;
280
+ }
281
+ /**
282
+ * Announce one standalone session (idempotent).
283
+ * @param summary - Host session summary facts.
284
+ */
285
+ async announce(summary) {
286
+ let sessionId;
287
+ try {
288
+ sessionId = assertSessionId(summary.sessionId);
289
+ } catch {
290
+ return;
291
+ }
292
+ if (typeof summary.cwd !== "string" || !isStandaloneCwd(summary.cwd)) return;
293
+ const existing = this.data.sessions[sessionId];
294
+ const entry = {
295
+ sessionId,
296
+ cwd: summary.cwd,
297
+ standalone: true,
298
+ createdAt: existing?.createdAt ?? Date.now(),
299
+ hadMessage: existing?.hadMessage === true
300
+ };
301
+ await this.mutate((data) => upsertEntry(data, entry));
302
+ }
303
+ /**
304
+ * Mark one session as having carried a user-authored durable message.
305
+ * @param sessionId - session identity.
306
+ */
307
+ async markActivity(sessionId) {
308
+ if (this.data.sessions[sessionId] === void 0) return;
309
+ await this.mutate((data) => markHadMessage(data, sessionId));
310
+ }
311
+ /**
312
+ * Mark archived sessions and clean the ones whose archive is visible.
313
+ * @param archivedSessionIds - the Host's complete archive set.
314
+ * @returns cleanup outcomes keyed by session.
315
+ */
316
+ async reconcileArchived(archivedSessionIds, stillArchived) {
317
+ const outcomes = /* @__PURE__ */ new Map();
318
+ if (archivedSessionIds.size === 0) return outcomes;
319
+ for (const entry of this.list()) {
320
+ if (!archivedSessionIds.has(entry.sessionId)) continue;
321
+ if (stillArchived !== void 0 && !stillArchived(entry.sessionId)) continue;
322
+ await this.mutate((data) => markArchived(data, entry.sessionId));
323
+ const outcome = await cleanupSessionDirectory(entry, this.ops, this.logger);
324
+ outcomes.set(entry.sessionId, outcome);
325
+ if (outcome.kind === "failed") {
326
+ await this.mutate(
327
+ (data) => markCleanupFailed(data, entry.sessionId, outcome.error)
328
+ );
329
+ } else {
330
+ await this.mutate((data) => removeEntry(data, entry.sessionId));
331
+ }
332
+ }
333
+ return outcomes;
334
+ }
335
+ /**
336
+ * Remove old empty session directories that neither the registry nor the
337
+ * live session store references. These are the residue of a failed create or
338
+ * a crash between mkdir and the session announcement.
339
+ * @param liveSessionIds - identities the Host currently holds.
340
+ * @param minimumAgeMs - only directories older than this are eligible.
341
+ * @returns identities whose directory was removed.
342
+ */
343
+ async sweepOrphanDirectories(liveSessionIds, minimumAgeMs = 6e4) {
344
+ let names;
345
+ try {
346
+ names = await readdir(this.rootDir);
347
+ } catch (error) {
348
+ this.logger.warn(`could not list the standalone root: ${errorMessage(error)}`);
349
+ return [];
350
+ }
351
+ const removed = [];
352
+ const now = Date.now();
353
+ for (const name2 of names) {
354
+ if (!name2.startsWith("session-")) continue;
355
+ if (this.data.sessions[name2] !== void 0) continue;
356
+ if (liveSessionIds.has(name2)) continue;
357
+ const target = join(this.rootDir, name2);
358
+ try {
359
+ const info = await stat(target);
360
+ if (!info.isDirectory()) continue;
361
+ if (now - info.mtimeMs < minimumAgeMs) continue;
362
+ if ((await readdir(target)).length > 0) continue;
363
+ await rmdir(target);
364
+ removed.push(name2);
365
+ this.logger.info(`removed orphan standalone session directory: ${target}`);
366
+ } catch (error) {
367
+ if (errorCode(error) !== "ENOENT") {
368
+ this.logger.warn(`could not reclaim orphan ${target}: ${errorMessage(error)}`);
369
+ }
370
+ }
371
+ }
372
+ return removed;
373
+ }
374
+ /**
375
+ * Apply one pure mutation and persist it when the reference changed.
376
+ * @param mutator - pure registry transform.
377
+ */
378
+ async mutate(mutator) {
379
+ const run = async () => {
380
+ if (!this.loaded) await this.load();
381
+ const next = mutator(this.data);
382
+ if (next === this.data) return;
383
+ this.data = next;
384
+ await this.write();
385
+ };
386
+ const pending = this.queue.then(run, run);
387
+ this.queue = pending.then(
388
+ () => void 0,
389
+ () => void 0
390
+ );
391
+ await pending;
392
+ }
393
+ /** Atomically replace the registry file. */
394
+ async write() {
395
+ const temporary = `${this.filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
396
+ const body = `${JSON.stringify(this.data, null, 2)}
397
+ `;
398
+ try {
399
+ await writeFile(temporary, body, "utf8");
400
+ await rename(temporary, this.filePath);
401
+ } catch (error) {
402
+ await rm(temporary, { force: true }).catch(() => void 0);
403
+ this.logger.warn(`could not persist the standalone registry: ${errorMessage(error)}`);
404
+ throw error;
405
+ }
406
+ }
407
+ };
408
+
409
+ // src/index.ts
410
+ var name = "standalone-session";
411
+ var SWEEP_INTERVAL_MS = 2e3;
412
+ var FIRST_SWEEP_DELAY_MS = 1500;
413
+ function readProperty(value, key) {
414
+ if (typeof value !== "object" || value === null) return void 0;
415
+ try {
416
+ return value[key];
417
+ } catch {
418
+ return void 0;
419
+ }
420
+ }
421
+ function resolveStandaloneRoot(home = homedir()) {
422
+ return join2(home, ".dsh", STANDALONE_DIR_NAME);
423
+ }
424
+ function resolveWorkspaceRegistry(ctx) {
425
+ const candidates = [ctx.root?.get?.("workspaceRegistry"), ctx.get("workspaceRegistry")];
426
+ for (const candidate of candidates) {
427
+ if (candidate !== void 0 && candidate !== null) return candidate;
428
+ }
429
+ return void 0;
430
+ }
431
+ function collectLiveSessionIds(ctx) {
432
+ const ids = /* @__PURE__ */ new Set();
433
+ const store = ctx.get("sessions");
434
+ const list = readProperty(store, "list");
435
+ if (typeof list !== "function") return ids;
436
+ let sessions;
437
+ try {
438
+ sessions = list.call(store);
439
+ } catch {
440
+ return ids;
441
+ }
442
+ if (!Array.isArray(sessions)) return ids;
443
+ for (const session of sessions) {
444
+ const id = readProperty(session, "id") ?? readProperty(readProperty(session, "header"), "id");
445
+ if (typeof id === "string") ids.add(id);
446
+ }
447
+ return ids;
448
+ }
449
+ function readArchivedIds(registry) {
450
+ let value;
451
+ try {
452
+ value = readProperty(registry, "archivedSessionIds");
453
+ } catch {
454
+ return /* @__PURE__ */ new Set();
455
+ }
456
+ if (!Array.isArray(value)) return /* @__PURE__ */ new Set();
457
+ return new Set(value.filter((id) => typeof id === "string"));
458
+ }
459
+ function apply(ctx) {
460
+ const baseLogger = prefixedLogger();
461
+ const logger = {
462
+ info: (message) => {
463
+ const write = ctx.logger?.info;
464
+ if (write === void 0) baseLogger.info(message);
465
+ else write.call(ctx.logger, message);
466
+ },
467
+ warn: (message) => {
468
+ const write = ctx.logger?.warn;
469
+ if (write === void 0) baseLogger.warn(message);
470
+ else write.call(ctx.logger, message);
471
+ }
472
+ };
473
+ const root = resolveStandaloneRoot();
474
+ const registry = new DurableStandaloneRegistry(root, logger);
475
+ logger.info(`standalone session root: ${root}`);
476
+ let disposed = false;
477
+ let sweeping = false;
478
+ const sweep = async () => {
479
+ if (disposed || sweeping) return;
480
+ sweeping = true;
481
+ try {
482
+ const workspaceRegistry = resolveWorkspaceRegistry(ctx);
483
+ if (workspaceRegistry !== void 0) {
484
+ const outcomes = await registry.reconcileArchived(
485
+ readArchivedIds(workspaceRegistry),
486
+ (sessionId) => readArchivedIds(workspaceRegistry).has(sessionId)
487
+ );
488
+ for (const [sessionId, outcome] of outcomes) {
489
+ if (outcome.kind === "failed") {
490
+ logger.warn(`cleanup for ${sessionId} will be retried: ${outcome.error}`);
491
+ }
492
+ }
493
+ } else {
494
+ logger.warn("workspace registry is unavailable; archive cleanup is deferred");
495
+ }
496
+ await registry.sweepOrphanDirectories(collectLiveSessionIds(ctx));
497
+ } catch (error) {
498
+ logger.warn(`cleanup sweep failed: ${error instanceof Error ? error.message : String(error)}`);
499
+ } finally {
500
+ sweeping = false;
501
+ }
502
+ };
503
+ const ready = registry.load().catch((error) => {
504
+ logger.warn(`registry load failed: ${error instanceof Error ? error.message : String(error)}`);
505
+ });
506
+ ctx.on("api-session/added", (summary) => {
507
+ const sessionId = summary?.sessionId;
508
+ const cwd = summary?.cwd;
509
+ if (typeof sessionId !== "string" || typeof cwd !== "string") return;
510
+ if (!isStandaloneCwd(cwd)) return;
511
+ logger.info(`observed standalone session ${sessionId}`);
512
+ void ready.then(() => registry.announce({ sessionId, cwd, blank: summary.blank })).catch((error) => {
513
+ logger.warn(
514
+ `could not record ${sessionId}: ${error instanceof Error ? error.message : String(error)}`
515
+ );
516
+ });
517
+ });
518
+ ctx.on("api-session/activity", (sessionId) => {
519
+ if (typeof sessionId !== "string") return;
520
+ void ready.then(() => registry.markActivity(sessionId)).catch((error) => {
521
+ logger.warn(
522
+ `could not mark activity: ${error instanceof Error ? error.message : String(error)}`
523
+ );
524
+ });
525
+ });
526
+ ctx.effect(() => {
527
+ disposed = false;
528
+ const first = setTimeout(() => {
529
+ void ready.then(sweep);
530
+ }, FIRST_SWEEP_DELAY_MS);
531
+ const timer = setInterval(() => {
532
+ void ready.then(sweep);
533
+ }, SWEEP_INTERVAL_MS);
534
+ return () => {
535
+ disposed = true;
536
+ clearTimeout(first);
537
+ clearInterval(timer);
538
+ };
539
+ }, "standalone-session: archive cleanup");
540
+ }
541
+ export {
542
+ apply,
543
+ name,
544
+ resolveStandaloneRoot
545
+ };
@@ -0,0 +1,40 @@
1
+ import type { ClientContext, Translate } from './dsh.js';
2
+ /** Host home, or undefined while the connection has not delivered it. */
3
+ export declare function hostHome(ctx: ClientContext): string | undefined;
4
+ /**
5
+ * Create a standalone session without navigating. The launcher owns navigation
6
+ * so it can decline to steal focus after the user has already moved on.
7
+ * @param ctx - client root context.
8
+ * @param t - localized copy.
9
+ * @returns the created session id.
10
+ */
11
+ export declare function createStandaloneSessionId(ctx: ClientContext, t: Translate): Promise<string>;
12
+ /**
13
+ * Create a standalone session and open its native conversation.
14
+ * @param ctx - client root context.
15
+ * @param t - localized copy.
16
+ * @returns the created session id.
17
+ */
18
+ export declare function startStandalone(ctx: ClientContext, t: Translate): Promise<string>;
19
+ /**
20
+ * Open the existing management panel through the public layout service.
21
+ * @param ctx - client root context.
22
+ * @param t - localized copy.
23
+ */
24
+ export declare function openManagementPanel(ctx: ClientContext, t: Translate): void;
25
+ /**
26
+ * Rename through the native Session face, which updates the title projection.
27
+ * @param ctx - client root context.
28
+ * @param sessionId - target session.
29
+ * @param title - user-entered title.
30
+ */
31
+ export declare function renameStandalone(ctx: ClientContext, sessionId: string, title: string): Promise<void>;
32
+ /**
33
+ * Delete through DSH's native archive. The host half observes the archive set
34
+ * and performs the directory cleanup; DSH exposes no physical session delete.
35
+ * @param ctx - client root context.
36
+ * @param sessionId - target session.
37
+ */
38
+ export declare function archiveStandalone(ctx: ClientContext, sessionId: string): Promise<void>;
39
+ /** Human-readable error text. */
40
+ export declare function messageOf(error: unknown): string;
@@ -0,0 +1,9 @@
1
+ import type { ClientContext, Translate } from './dsh.js';
2
+ /**
3
+ * Install the composer adapter for as long as the composer slot declaration
4
+ * lives.
5
+ * @param ctx - client root context.
6
+ * @param t - localized copy for the unlocked placeholder.
7
+ * @returns a disposer restoring every wrapped entry.
8
+ */
9
+ export declare function patchComposer(ctx: ClientContext, t: Translate): () => void;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Browser components: the dedicated Standalone Sessions panel, the sidebar
3
+ * entry/action, the native-row badge decorations, the hero badge and the toast.
4
+ *
5
+ * The designer constraint is deliberately narrow: this plugin never renders a
6
+ * chat surface. Opening a row navigates to the native Conversation, and rename
7
+ * and delete call the native Session/Workspace controllers.
8
+ */
9
+ import { type ReactElement } from 'react';
10
+ import type { ClientContext, Translate } from './dsh.js';
11
+ interface LocalizedProps {
12
+ readonly t?: Translate;
13
+ }
14
+ /** Small chat-with-spark mark used by the command and sidebar entry. */
15
+ export declare function StandaloneIcon({ size, active, }: {
16
+ readonly size?: number;
17
+ readonly active?: boolean;
18
+ }): ReactElement;
19
+ /** Launcher glyph: a chat mark with a plus badge. */
20
+ export declare function createLauncherEntryIcon(): ({ size, active, }: {
21
+ readonly size?: number;
22
+ readonly active?: boolean;
23
+ }) => ReactElement;
24
+ /** Sidebar footer action that opens the management panel. */
25
+ export declare function createManageFooterAction(ctx: ClientContext): ({ wide, t, }: {
26
+ readonly wide?: boolean;
27
+ } & LocalizedProps) => ReactElement;
28
+ /**
29
+ * Launcher main panel. Selecting the sidebar row mounts this component, which
30
+ * creates (or reuses a blank) standalone session and then opens its native
31
+ * conversation. A real row click must have armed it; a layout restore without
32
+ * a gesture gets an explicit CTA instead of a silent session.
33
+ */
34
+ export declare function createStandaloneLauncher(ctx: ClientContext): (props: LocalizedProps) => ReactElement;
35
+ /** Badge in the native New Session hero options row. */
36
+ export declare function createHeroBadge(ctx: ClientContext): ({ sessionId, t, }: {
37
+ readonly sessionId?: string;
38
+ } & LocalizedProps) => ReactElement | null;
39
+ /** Tiny leading mark on a native Ungrouped session row. */
40
+ export declare function createRowLeading(ctx: ClientContext): ({ sessionId, t, }: {
41
+ readonly sessionId?: string;
42
+ } & LocalizedProps) => ReactElement | null;
43
+ /** Full badge line in the native session row hover card. */
44
+ export declare function createRowHover(ctx: ClientContext): ({ sessionId, t, }: {
45
+ readonly sessionId?: string;
46
+ } & LocalizedProps) => ReactElement | null;
47
+ /** Frame-wide toast for creation and cleanup-adjacent failures. */
48
+ export declare function StandaloneToast(): ReactElement | null;
49
+ /**
50
+ * Factory for the keyed `main` panel.
51
+ * @param ctx - client root context.
52
+ * @returns the panel component.
53
+ */
54
+ export declare function createStandalonePanel(ctx: ClientContext): (props: LocalizedProps) => ReactElement;
55
+ export {};
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The one browser-side creation path. Kept dependency-injected so the failure
3
+ * branch is unit-tested without a DSH runtime.
4
+ */
5
+ import { type UuidSource } from '../shared/standalone.js';
6
+ /** Injected collaborators for {@link createStandaloneSession}. */
7
+ export interface StandaloneCreateDeps {
8
+ /** Host user home, from `ctx.remote.$host.home`. */
9
+ readonly home: string | undefined;
10
+ /** Localized message used when the host home is unavailable. */
11
+ readonly noHomeMessage: string;
12
+ /** Session Controller create. */
13
+ create(options: {
14
+ cwd: string;
15
+ sessionId: string;
16
+ }): Promise<string>;
17
+ /** Optional navigation: show the created session's conversation. */
18
+ open?(sessionId: string): void;
19
+ readonly randomUuid?: UuidSource;
20
+ }
21
+ /** Absolute POSIX or Windows path check without importing `node:path`. */
22
+ export declare function isAbsoluteHostPath(path: string): boolean;
23
+ /**
24
+ * Create a standalone session with a durable marker cwd.
25
+ *
26
+ * Failure semantics: a throw from `create` propagates before `open`, so no
27
+ * conversation is selected for a session that was never created. A throw from
28
+ * `open` also propagates; the session itself remains valid and is listed by the
29
+ * panel, which is better than deleting a session the Host already committed.
30
+ * @param deps - injected host home, create, and optional navigation.
31
+ * @returns the session identity returned by the Session Controller.
32
+ */
33
+ export declare function createStandaloneSession(deps: StandaloneCreateDeps): Promise<string>;