@xmoon76/dsh-pi-tui 0.4.9 → 0.5.1

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.
@@ -0,0 +1,2349 @@
1
+ import { ft as isFooterLayout, pt as parseFooterLayout } from "./extensions-1WYBUQ8I.mjs";
2
+ import { accessSync, constants, readdirSync, statSync } from "node:fs";
3
+ import { delimiter, join, posix, win32 } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { spawn } from "node:child_process";
6
+ //#region src/runtime/process/errors.ts
7
+ /**
8
+ * Shared, TOTAL error-observation helpers for every async boundary in the
9
+ * TUI: the owned/detached entries (`tasks.ts`), the lifecycle roots
10
+ * (startup, exit, flush), the per-task callbacks and the diagnostics
11
+ * formatter. One implementation, one behavior — no drift between modules.
12
+ *
13
+ * CONTRACT (deliberately scoped, honestly worded): the SYNC boundary is
14
+ * total — observing any legal JavaScript value (a Proxy with a throwing
15
+ * `getPrototypeOf` trap, a throwing `message` getter, a throwing
16
+ * `toString`/`Symbol.toPrimitive`) can never make THIS code throw or
17
+ * reject; every observation is protected and every fallback is a fixed
18
+ * constant that never touches the value again. What is NOT guaranteed: an
19
+ * observer (getter/trap/coercion) that, WHILE being observed, spawns its
20
+ * OWN detached async work (e.g. `void Promise.reject(...)` inside
21
+ * `toString`) — that rejection belongs to the observer, not to this
22
+ * boundary, and no synchronous try/catch can intercept it. Callers must
23
+ * not describe this module as a strict "any legal value, zero side
24
+ * effects" guarantee.
25
+ * @module @xmoon76/dsh-pi-tui/runtime/process/errors
26
+ */
27
+ /**
28
+ * Describe an arbitrary thrown value as a string WITHOUT ever throwing:
29
+ * `instanceof`, property reads and coercion are all inside the protection,
30
+ * the fallbacks are FIXED constants that never touch the value again, and
31
+ * a hostile Proxy/getter/`Symbol.toPrimitive` yields a stable placeholder
32
+ * instead of escaping. (Best-effort beyond the sync boundary — see the
33
+ * module contract.)
34
+ */
35
+ function safeErrorMessage(value) {
36
+ try {
37
+ if (value instanceof Error) try {
38
+ const message = value.message;
39
+ return typeof message === "string" ? message : "<error with non-string message>";
40
+ } catch {
41
+ return "<error with unreadable message>";
42
+ }
43
+ return String(value);
44
+ } catch {
45
+ return "<unprintable error>";
46
+ }
47
+ }
48
+ /** Describe an arbitrary thrown value using its normal `String` form without
49
+ * allowing hostile coercion to escape the async boundary. */
50
+ function safeErrorString(value) {
51
+ try {
52
+ return String(value);
53
+ } catch {
54
+ return "<unprintable error>";
55
+ }
56
+ }
57
+ //#endregion
58
+ //#region src/runtime/process/tasks.ts
59
+ /**
60
+ * Whether an error represents an abort/cancel rather than a real failure.
61
+ * TOTAL over the whole `unknown` domain: every probe (prototype-chain
62
+ * walk, `name`/`code` getters) is inside the protection, and a hostile
63
+ * value that throws while being observed simply classifies as NOT a
64
+ * cancellation — recognizing a cancellation must never mint a new primary
65
+ * error.
66
+ */
67
+ function isCancellation(error) {
68
+ try {
69
+ if (!(error instanceof Error)) return false;
70
+ return error.name === "AbortError" || error.code === "ABORT_ERR";
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+ /** Build a cancellation-shaped error the classifier recognizes (a plain
76
+ * `new Error('...')` would route through the failure path). */
77
+ function cancellationError(message) {
78
+ const error = new Error(message);
79
+ error.name = "AbortError";
80
+ error.code = "ABORT_ERR";
81
+ return error;
82
+ }
83
+ /** Read the live session id for diagnostics WITHOUT ever throwing: a
84
+ * throwing `sessionId()` callback degrades to `undefined` (diagnostics
85
+ * must not crash classification or the final sink). */
86
+ function safeSessionId(options) {
87
+ try {
88
+ return options.sessionId?.();
89
+ } catch {
90
+ return;
91
+ }
92
+ }
93
+ /**
94
+ * Invoke the task factory SYNCHRONOUSLY (an ownership action inside the
95
+ * factory — a stop, a latch, a spawn — must take effect before the helper
96
+ * returns, so two calls in one input batch cannot both start), and convert
97
+ * a synchronous throw into a rejected promise so it classifies exactly like
98
+ * a rejection.
99
+ */
100
+ function invokeTask(task) {
101
+ try {
102
+ return Promise.resolve(task());
103
+ } catch (error) {
104
+ return Promise.reject(error);
105
+ }
106
+ }
107
+ /**
108
+ * Observe the settlement of an ALREADY-OWNED promise without taking ownership.
109
+ * The callback runs on fulfilment or rejection and its own result is dropped,
110
+ * so bookkeeping (for example removing a promise from a teardown-drained set)
111
+ * never adds a second, unhandled rejection branch next to the real owner's
112
+ * failure handling. Never use this to START work — that is `runDetached` /
113
+ * `runOwned`.
114
+ * @param promise - the promise whose failure semantics already have an owner.
115
+ * @param onSettled - runs once, on either settlement.
116
+ */
117
+ function observeSettled(promise, onSettled) {
118
+ promise.then(onSettled, onSettled);
119
+ }
120
+ /**
121
+ * Attach rejection handling to a fire-and-forget task. The factory is
122
+ * invoked synchronously (a synchronous throw is a rejection like any
123
+ * other); every rejection is caught and classified:
124
+ * - cancellation → debug diagnostics only (never a user error);
125
+ * - recoverable (per the options hook) → notify + warn;
126
+ * - anything else → warn.
127
+ * The log line carries the task label and the live session id — never the
128
+ * task payload (no API keys, prompt bodies, or shell output). A throwing
129
+ * notify/diag/sessionId path cannot escape: it lands in the handler-failure
130
+ * sink, which is itself total over the `unknown` domain.
131
+ */
132
+ function runDetached(label, task, options) {
133
+ invokeTask(task).catch((error) => {
134
+ if (isCancellation(error)) {
135
+ options.diag.debug(label, {
136
+ session: safeSessionId(options),
137
+ cancelled: true
138
+ });
139
+ return;
140
+ }
141
+ const message = safeErrorMessage(error);
142
+ options.diag.warn(label, {
143
+ session: safeSessionId(options),
144
+ error: message
145
+ });
146
+ if (options.recoverable?.(error) === true) options.notify?.(`${label}: ${message}`);
147
+ }).catch(handlerFailure(label, options));
148
+ }
149
+ /**
150
+ * The owned-workflow counterpart of {@link runDetached}: a fire-and-forget
151
+ * task whose RESULT drives user-visible state (a settled card, a restored
152
+ * draft, a queue mutation, a notification). The factory starts
153
+ * SYNCHRONOUSLY; the TASK phase (factory throw / task rejection) classifies
154
+ * with the error shape plus the task-local predicate; the onResult phase is
155
+ * classified as a PRIMARY failure — ERROR + onError, never a cancellation,
156
+ * regardless of error shape or task-local state; a terminal side-effect
157
+ * handler's (onCancel/onError/classifier) own sync/async throw is wrapped
158
+ * in a {@link HandlerFailure} and lands in the `${label} handler failed`
159
+ * sink exactly once, without mutating the thrown value. Never a bare
160
+ * `void somePromise()`: every owned flow goes through this entry
161
+ * (AGENTS.md).
162
+ */
163
+ function runOwned(label, task, options) {
164
+ invokeTask(task).then((result) => options.onResult?.(result), (error) => {
165
+ return classify(label, options, error, true);
166
+ }).catch((error) => {
167
+ if (isHandlerFailure(error)) throw error;
168
+ return classify(label, options, error, false);
169
+ }).catch(handlerFailure(label, options));
170
+ }
171
+ /**
172
+ * Classify one failure and record the classification diagnostics, then run
173
+ * the matching per-task callback. `allowCancellation` gates ALL cancellation
174
+ * classification: the task phase may treat an AbortError or a task-local
175
+ * state as a cancellation; the onResult phase may not (a result-consumer
176
+ * failure is a failure). A terminal side-effect handler's own sync/async
177
+ * failure is wrapped in {@link HandlerFailure} and propagated to the
178
+ * handler-failure sink — never re-classified, never repeated, never
179
+ * escaped. All diagnostics go through the never-throw helpers, so a
180
+ * hostile thrown value or a throwing sessionId/diag can never mint a new
181
+ * primary error.
182
+ */
183
+ function classify(label, options, error, allowCancellation) {
184
+ if (allowCancellation && (isCancellation(error) || taskLocalCancellation(options, error))) {
185
+ options.diag.debug(label, {
186
+ session: safeSessionId(options),
187
+ cancelled: true
188
+ });
189
+ if (options.onCancel !== void 0) return guardHandler(() => options.onCancel(error));
190
+ return;
191
+ }
192
+ options.diag.error(label, {
193
+ session: safeSessionId(options),
194
+ error: safeErrorMessage(error)
195
+ });
196
+ if (options.onError !== void 0) return guardHandler(() => options.onError(error));
197
+ }
198
+ /** The task-local predicate (cancellation classification is already gated
199
+ * by the caller). A throwing predicate is wrapped as a handler failure — it
200
+ * never re-routes the original error into the wrong classification and is
201
+ * never itself re-classified. */
202
+ function taskLocalCancellation(options, error) {
203
+ if (options.isCancellation === void 0) return false;
204
+ try {
205
+ return options.isCancellation(error) === true;
206
+ } catch (classifierError) {
207
+ throw new HandlerFailure(classifierError);
208
+ }
209
+ }
210
+ /**
211
+ * Immutable wrapper for a terminal side-effect handler's own failure. The
212
+ * thrown value is NEVER mutated (a primitive, null, or a frozen/non-
213
+ * extensible error must survive intact); the wrapper carries the original
214
+ * cause to the handler-failure sink, and the middle classification catch
215
+ * recognizes it and forwards it unchanged — so `onError`/`onCancel` run
216
+ * exactly once even when they throw.
217
+ */
218
+ var HandlerFailure = class {
219
+ cause;
220
+ constructor(cause) {
221
+ this.cause = cause;
222
+ handlerFailures.add(this);
223
+ }
224
+ };
225
+ /** Identity registry for the internal wrappers: `WeakSet.has` compares by
226
+ * identity and never walks the argument's prototype chain, so a hostile
227
+ * thrown value (a Proxy with a throwing `getPrototypeOf` trap) can be
228
+ * tested safely — `instanceof HandlerFailure` on an untrusted value could
229
+ * itself throw. */
230
+ const handlerFailures = /* @__PURE__ */ new WeakSet();
231
+ /** Whether a value is one of OUR wrapper instances (identity only; total
232
+ * over the whole `unknown` domain). */
233
+ function isHandlerFailure(value) {
234
+ return typeof value === "object" && value !== null && handlerFailures.has(value);
235
+ }
236
+ /** Run a per-task terminal callback; a sync or async failure is wrapped in
237
+ * {@link HandlerFailure} so the chain forwards it to the handler-failure
238
+ * sink instead of re-classifying it (and never repeats the callback). */
239
+ function guardHandler(invoke) {
240
+ try {
241
+ const outcome = invoke();
242
+ if (outcome !== void 0) return outcome.catch((error) => {
243
+ throw new HandlerFailure(error);
244
+ });
245
+ return;
246
+ } catch (error) {
247
+ throw new HandlerFailure(error);
248
+ }
249
+ }
250
+ /**
251
+ * The final sink of both helpers: a terminal side-effect handler
252
+ * (onResult's own invocation aside — see {@link runOwned}), a task-local
253
+ * classifier, or the diagnostics channel itself threw. The ENTIRE body is
254
+ * protected — wrapper unwrapping, error description, the session read and
255
+ * the diag call all live inside the try, and the fallback is a fixed
256
+ * constant that never touches the hostile value again — so the sink is a
257
+ * total function over the `unknown` domain: it can NEVER reject, and there
258
+ * is no lower sink to leak into.
259
+ */
260
+ function handlerFailure(label, options) {
261
+ return (error) => {
262
+ try {
263
+ const cause = isHandlerFailure(error) ? error.cause : error;
264
+ options.diag.error(`${label} handler failed`, {
265
+ session: safeSessionId(options),
266
+ error: safeErrorMessage(cause)
267
+ });
268
+ } catch {}
269
+ };
270
+ }
271
+ //#endregion
272
+ //#region src/runtime/session-reader-port.ts
273
+ /** The official context-pressure occupancy numerator shared by both
274
+ * backends: `projectedTokens ?? pressureTokens` over the raw projection
275
+ * value (an absent/foreign-shaped field is not coerced; a projection with
276
+ * neither field reads unmeasured). */
277
+ function contextPressureOccupancy(value) {
278
+ if (typeof value !== "object" || value === null) return void 0;
279
+ const pressure = value;
280
+ if (typeof pressure.projectedTokens === "number") return pressure.projectedTokens;
281
+ if (typeof pressure.pressureTokens === "number") return pressure.pressureTokens;
282
+ }
283
+ //#endregion
284
+ //#region src/domain/footer/command-config.ts
285
+ /** The timeout ceiling. */
286
+ const MAX_COMMAND_TIMEOUT_MS = 1e3;
287
+ /** The minimum refresh interval (plan §17.7). */
288
+ const MIN_COMMAND_REFRESH_MS = 1e3;
289
+ //#endregion
290
+ //#region src/domain/footer/command-trust.ts
291
+ /**
292
+ * The footer command TRUST GATE (plan §17.4): a repository/project config
293
+ * must NEVER silently trigger arbitrary shell execution. The command is
294
+ * read ONLY from the settings descriptor's USER layer (the stored
295
+ * document's user section) — never from the merged/resolved value, which
296
+ * a project layer could influence. When the user layer cannot prove the
297
+ * command, command mode is disabled and the native layout applies.
298
+ * @module @xmoon76/dsh-pi-tui/domain/footer/command-trust
299
+ */
300
+ /** Validate the persisted footerCommand value (bounds per plan §17.3). */
301
+ function parseFooterCommandConfig(input) {
302
+ if (typeof input !== "object" || input === null) return void 0;
303
+ const raw = input;
304
+ if (raw.schemaVersion !== 1) return void 0;
305
+ if (typeof raw.command !== "string" || raw.command.trim() === "") return void 0;
306
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(raw.command)) return void 0;
307
+ const timeoutMs = raw.timeoutMs === void 0 ? 300 : typeof raw.timeoutMs === "number" && Number.isFinite(raw.timeoutMs) ? Math.min(Math.max(1, raw.timeoutMs), MAX_COMMAND_TIMEOUT_MS) : 300;
308
+ const refreshIntervalMs = raw.refreshIntervalMs === void 0 ? MIN_COMMAND_REFRESH_MS : typeof raw.refreshIntervalMs === "number" && Number.isFinite(raw.refreshIntervalMs) ? Math.max(MIN_COMMAND_REFRESH_MS, raw.refreshIntervalMs) : MIN_COMMAND_REFRESH_MS;
309
+ const maxRows = raw.maxRows === void 0 ? 1 : typeof raw.maxRows === "number" && Number.isFinite(raw.maxRows) ? Math.min(Math.max(1, Math.floor(raw.maxRows)), 2) : 1;
310
+ return {
311
+ command: raw.command,
312
+ timeoutMs,
313
+ refreshIntervalMs,
314
+ maxRows
315
+ };
316
+ }
317
+ /**
318
+ * Resolve the TRUSTED command config: the footerCommand value must be
319
+ * present in the USER layer of the tui-app settings descriptor. A
320
+ * merged/project-supplied value is refused (undefined = command mode
321
+ * disabled, native fallback, diagnostic warning).
322
+ * @param descriptors - the settings provider's describe() output.
323
+ * @param namespace - the tui-app settings/profile Config namespace value.
324
+ * @returns the validated config, or undefined when untrusted/invalid.
325
+ */
326
+ function resolveTrustedFooterCommand(descriptors, namespace) {
327
+ if (descriptors === void 0) return void 0;
328
+ const descriptor = descriptors.find((entry) => entry.ns === namespace);
329
+ if (descriptor === void 0) return void 0;
330
+ const user = descriptor.user;
331
+ if (typeof user !== "object" || user === null) return void 0;
332
+ const raw = user.footerCommand;
333
+ return parseFooterCommandConfig(raw);
334
+ }
335
+ /**
336
+ * Resolve the USER layer's declared footer mode. Command mode must be
337
+ * user-layer-owned as well: a project can flip the MERGED `footer:
338
+ * command`, but the gate never lets a project config silently trigger the
339
+ * user's command — the user layer must declare the command mode too.
340
+ * @returns the user layer's footer value, or undefined when absent.
341
+ */
342
+ function resolveUserLayerFooterMode(descriptors, namespace) {
343
+ if (descriptors === void 0) return void 0;
344
+ const descriptor = descriptors.find((entry) => entry.ns === namespace);
345
+ if (descriptor === void 0) return void 0;
346
+ const user = descriptor.user;
347
+ if (typeof user !== "object" || user === null) return void 0;
348
+ const mode = user.footer;
349
+ return typeof mode === "string" ? mode : void 0;
350
+ }
351
+ /**
352
+ * Resolve the USER layer's declared custom layout (PR D activation trust):
353
+ * the layout whose refs MAY authorize custom command items — but only
354
+ * while the USER layer itself declares footer: custom (see
355
+ * resolveUserCommandItemActivationIds). A project can supply a MERGED
356
+ * `footerLayout` for rendering, but it can never activate a dormant USER
357
+ * command — the same principle as the command-mode gate above: the user
358
+ * layer must own the layout that decides what executes.
359
+ * @returns the validated USER-layer layout, or undefined when absent or
360
+ * invalid (an invalid user layout authorizes nothing — fail-safe).
361
+ */
362
+ function resolveUserLayerFooterLayout(descriptors, namespace) {
363
+ if (descriptors === void 0) return void 0;
364
+ const descriptor = descriptors.find((entry) => entry.ns === namespace);
365
+ if (descriptor === void 0) return void 0;
366
+ const user = descriptor.user;
367
+ if (typeof user !== "object" || user === null) return void 0;
368
+ const layout = user.footerLayout;
369
+ const parsed = parseFooterLayout(layout);
370
+ return isFooterLayout(parsed) ? parsed : void 0;
371
+ }
372
+ /**
373
+ * Resolve the USER layer's declared footerFallbackMode (the native mode
374
+ * the user's own command-mode fallback restores).
375
+ */
376
+ function resolveUserLayerFooterFallbackMode(descriptors, namespace) {
377
+ if (descriptors === void 0) return void 0;
378
+ const descriptor = descriptors.find((entry) => entry.ns === namespace);
379
+ if (descriptor === void 0) return void 0;
380
+ const user = descriptor.user;
381
+ if (typeof user !== "object" || user === null) return void 0;
382
+ const mode = user.footerFallbackMode;
383
+ return typeof mode === "string" ? mode : void 0;
384
+ }
385
+ /** The shared empty authorization set. */
386
+ const EMPTY_IDS = /* @__PURE__ */ new Set();
387
+ /** Every item id a layout references (the authorization projection). */
388
+ function layoutRefIds(layout) {
389
+ const ids = /* @__PURE__ */ new Set();
390
+ for (const row of layout.rows) {
391
+ for (const ref of row.left) ids.add(ref.id);
392
+ for (const ref of row.right) ids.add(ref.id);
393
+ }
394
+ return ids;
395
+ }
396
+ /**
397
+ * The ids the USER layer AUTHORIZES for custom command item execution
398
+ * (PR D activation trust, mode-gated): the USER custom layout's refs, but
399
+ * ONLY while the USER layer itself declares `footer: custom`. A stale
400
+ * leftover layout under `footer: default/compact` (the /settings switch
401
+ * deliberately keeps the old layout) authorizes NOTHING — a project
402
+ * flipping the MERGED mode to custom can never resurrect a dormant USER
403
+ * command. `footer: command` authorizes nothing while the whole-footer
404
+ * command surface runs (the caller suspends per-item runners); the native
405
+ * fallback case uses resolveUserCommandItemFallbackActivationIds.
406
+ */
407
+ function resolveUserCommandItemActivationIds(descriptors, namespace) {
408
+ if (resolveUserLayerFooterMode(descriptors, namespace) !== "custom") return EMPTY_IDS;
409
+ const layout = resolveUserLayerFooterLayout(descriptors, namespace);
410
+ return layout === void 0 ? EMPTY_IDS : layoutRefIds(layout);
411
+ }
412
+ /**
413
+ * The ids authorized for the native FALLBACK surface (a merged
414
+ * `footer: command` the USER layer does not own). The FULL semantic is
415
+ * encoded HERE so no caller can forget the outer mode gate:
416
+ *
417
+ * USER footer === 'command'
418
+ * && USER footerFallbackMode === 'custom'
419
+ * && valid USER footerLayout
420
+ * → the layout's refs
421
+ * otherwise → empty
422
+ *
423
+ * A USER who never opted into command mode (footer: default/compact)
424
+ * authorizes NOTHING even with stale fallback metadata; a PROJECT
425
+ * forcing the merged command mode can never turn it into execution
426
+ * authorization.
427
+ */
428
+ function resolveUserCommandItemFallbackActivationIds(descriptors, namespace) {
429
+ if (resolveUserLayerFooterMode(descriptors, namespace) !== "command") return EMPTY_IDS;
430
+ if (resolveUserLayerFooterFallbackMode(descriptors, namespace) !== "custom") return EMPTY_IDS;
431
+ const layout = resolveUserLayerFooterLayout(descriptors, namespace);
432
+ return layout === void 0 ? EMPTY_IDS : layoutRefIds(layout);
433
+ }
434
+ //#endregion
435
+ //#region src/domain/footer/custom-items.ts
436
+ /**
437
+ * User-owned custom footer item definitions (PR C + PR D), neutral half
438
+ * (TS5 §13.3): the persisted raw settings shapes, their validation and
439
+ * normalization, and the config-effective values. Custom definitions are
440
+ * persisted separately from FooterLayoutV1: the layout stores only the
441
+ * canonical `user:*` reference, while this module owns what that item IS.
442
+ * The presentation compilation (FooterItemDefinition / runtime catalog) lives
443
+ * in `tui/footer/custom-item-catalog.ts`.
444
+ * @module @xmoon76/dsh-pi-tui/domain/footer/custom-items
445
+ */
446
+ /** The namespace reserved for user-created definitions. */
447
+ const CUSTOM_FOOTER_ITEM_PREFIX = "user:";
448
+ /** The default refresh interval for a custom command item (PR D §5.2):
449
+ * several command items can coexist, so the default must not spawn one
450
+ * process per item per second (the whole-footer M5 default is 1s). */
451
+ const DEFAULT_CUSTOM_COMMAND_REFRESH_MS = 5e3;
452
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/u;
453
+ const TONES = /* @__PURE__ */ new Set([
454
+ "primary",
455
+ "accent",
456
+ "text",
457
+ "textStrong",
458
+ "textDim",
459
+ "textMuted",
460
+ "border",
461
+ "success",
462
+ "warning",
463
+ "error",
464
+ "roleUser",
465
+ "shellMode"
466
+ ]);
467
+ /** The EXACT key set a v1 text definition may carry. A known-kind object
468
+ * with any OTHER key is forward-compatible raw data (plan §12.1 — an
469
+ * "unknown field object" keeps its raw slot): a future client's field must
470
+ * never be silently normalized away by an unrelated save. */
471
+ const TEXT_ITEM_KEYS = /* @__PURE__ */ new Set([
472
+ "schemaVersion",
473
+ "id",
474
+ "kind",
475
+ "text",
476
+ "tone"
477
+ ]);
478
+ /** The EXACT key set a v1 command definition may carry (same rule). */
479
+ const COMMAND_ITEM_KEYS = /* @__PURE__ */ new Set([
480
+ "schemaVersion",
481
+ "id",
482
+ "kind",
483
+ "command",
484
+ "refreshIntervalMs",
485
+ "timeoutMs",
486
+ "tone"
487
+ ]);
488
+ /** Whether the raw object carries a key outside the allowed v1 set. */
489
+ function hasUnknownKeys(raw, allowed) {
490
+ for (const key of Object.keys(raw)) if (!allowed.has(key)) return true;
491
+ return false;
492
+ }
493
+ /** The validated runner config for one command definition (maxRows is
494
+ * always 1 — a custom command item is exactly one line; the composer owns
495
+ * width/truncation). The custom-item DEFAULT refresh is 5s (several items
496
+ * can coexist — the whole-footer 1s default would spawn a process per
497
+ * item per second), so an ABSENT refreshIntervalMs is projected to the
498
+ * custom default BEFORE the shared parser runs: an absent default and an
499
+ * explicit 5s must produce the SAME cadence. The parser already validated
500
+ * the definition, so this is defensive. This is the ONE projection both
501
+ * the runtime and the effective-value helpers consume, so the UI, the
502
+ * dirty comparator and the real execution can never drift apart. */
503
+ function customCommandConfigOf(item) {
504
+ return parseFooterCommandConfig({
505
+ schemaVersion: 1,
506
+ command: item.command,
507
+ timeoutMs: item.timeoutMs,
508
+ refreshIntervalMs: item.refreshIntervalMs ?? 5e3,
509
+ maxRows: 1
510
+ });
511
+ }
512
+ /** The effective refresh interval of a command definition (absent = the
513
+ * 5s default). Dirty comparison and the runtime use the EFFECTIVE value so
514
+ * an absent default and an explicit default are the same fact. The value
515
+ * is the SAME normalized value the runner executes (the shared parser
516
+ * clamps refresh to >= 1s): a hand-edited out-of-range raw value is
517
+ * preserved in storage for forward compatibility, but the UI and the
518
+ * dirty comparator never lie about the real cadence. */
519
+ function effectiveCustomCommandRefreshMs(item) {
520
+ return customCommandConfigOf(item)?.refreshIntervalMs ?? 5e3;
521
+ }
522
+ /** The effective timeout of a command definition (absent = the whole-footer
523
+ * 300ms default; the shared parser clamps to 1..1000ms — the UI reports
524
+ * the same value the runner enforces). */
525
+ function effectiveCustomCommandTimeoutMs(item) {
526
+ return customCommandConfigOf(item)?.timeoutMs ?? 300;
527
+ }
528
+ function codePointLength(text) {
529
+ return [...text].length;
530
+ }
531
+ /** Normalize the name typed by the user and return undefined for a name that
532
+ * cannot form a stable user namespace id. Colons are reserved so a user name
533
+ * cannot impersonate another canonical namespace such as `ext:*`. */
534
+ function normalizeCustomItemName(input) {
535
+ const name = input.trim();
536
+ if (name === "" || CONTROL_CHARS.test(name)) return void 0;
537
+ if (name.includes(":")) return void 0;
538
+ if (codePointLength(name) > 64) return void 0;
539
+ return name;
540
+ }
541
+ /** Convert a user-facing name to the deterministic persisted id. */
542
+ function customItemId(name) {
543
+ const normalized = normalizeCustomItemName(name);
544
+ return normalized === void 0 ? void 0 : `${CUSTOM_FOOTER_ITEM_PREFIX}${normalized}`;
545
+ }
546
+ /** Return the user-facing part of a canonical id. */
547
+ function customItemName(id) {
548
+ return id.startsWith("user:") ? id.slice(5) : id;
549
+ }
550
+ function isTone(value) {
551
+ return value === "auto" || typeof value === "string" && TONES.has(value);
552
+ }
553
+ /** Parse one persisted definition. Invalid definitions are rejected at this
554
+ * boundary; callers can keep the rest of the collection. */
555
+ function parseFooterCustomItem(input) {
556
+ try {
557
+ if (typeof input !== "object" || input === null || Array.isArray(input)) return void 0;
558
+ const raw = input;
559
+ if (raw.schemaVersion !== 1) return void 0;
560
+ if (typeof raw.id !== "string" || customItemId(customItemName(raw.id)) !== raw.id) return void 0;
561
+ if (raw.tone !== void 0 && !isTone(raw.tone)) return void 0;
562
+ const tone = raw.tone === void 0 ? void 0 : raw.tone;
563
+ if (raw.kind === "text") {
564
+ if (hasUnknownKeys(raw, TEXT_ITEM_KEYS)) return void 0;
565
+ if (typeof raw.text !== "string" || raw.text.trim() === "" || CONTROL_CHARS.test(raw.text)) return void 0;
566
+ if (codePointLength(raw.text) > 256) return void 0;
567
+ return {
568
+ schemaVersion: 1,
569
+ id: raw.id,
570
+ kind: "text",
571
+ text: raw.text,
572
+ ...tone === void 0 ? {} : { tone }
573
+ };
574
+ }
575
+ if (raw.kind === "command") {
576
+ if (hasUnknownKeys(raw, COMMAND_ITEM_KEYS)) return void 0;
577
+ const refresh = typeof raw.refreshIntervalMs === "number" && Number.isFinite(raw.refreshIntervalMs) ? raw.refreshIntervalMs : void 0;
578
+ const timeout = typeof raw.timeoutMs === "number" && Number.isFinite(raw.timeoutMs) ? raw.timeoutMs : void 0;
579
+ const config = parseFooterCommandConfig({
580
+ schemaVersion: 1,
581
+ command: raw.command,
582
+ timeoutMs: timeout,
583
+ refreshIntervalMs: refresh,
584
+ maxRows: 1
585
+ });
586
+ if (config === void 0) return void 0;
587
+ const command = config.command;
588
+ return {
589
+ schemaVersion: 1,
590
+ id: raw.id,
591
+ kind: "command",
592
+ command,
593
+ ...refresh === void 0 ? {} : { refreshIntervalMs: refresh },
594
+ ...timeout === void 0 ? {} : { timeoutMs: timeout },
595
+ ...tone === void 0 ? {} : { tone }
596
+ };
597
+ }
598
+ return;
599
+ } catch {
600
+ return;
601
+ }
602
+ }
603
+ /** Parse the collection without letting one malformed or duplicate entry
604
+ * break startup. The first definition for an id wins deterministically. A
605
+ * hostile collection-level proxy/iterator fails closed as one invalid
606
+ * collection instead of escaping into startup or reload. */
607
+ function parseFooterCustomItems(input) {
608
+ try {
609
+ if (input === void 0) return {
610
+ items: [],
611
+ invalidCount: 0
612
+ };
613
+ if (!Array.isArray(input)) return {
614
+ items: [],
615
+ invalidCount: 1
616
+ };
617
+ const items = [];
618
+ const ids = /* @__PURE__ */ new Set();
619
+ let invalidCount = 0;
620
+ for (const candidate of input) {
621
+ const item = parseFooterCustomItem(candidate);
622
+ if (item === void 0 || ids.has(item.id)) {
623
+ invalidCount += 1;
624
+ continue;
625
+ }
626
+ ids.add(item.id);
627
+ items.push(item);
628
+ }
629
+ return {
630
+ items,
631
+ invalidCount
632
+ };
633
+ } catch {
634
+ return {
635
+ items: [],
636
+ invalidCount: 1
637
+ };
638
+ }
639
+ }
640
+ /** Every item id a layout references (the authorization / active-set
641
+ * projection). An absent layout activates nothing. */
642
+ function activeFooterItemIds(layout) {
643
+ const ids = /* @__PURE__ */ new Set();
644
+ if (layout === void 0) return ids;
645
+ for (const row of layout.rows) {
646
+ for (const ref of row.left) ids.add(ref.id);
647
+ for (const ref of row.right) ids.add(ref.id);
648
+ }
649
+ return ids;
650
+ }
651
+ /** The EXECUTABLE command item ids (PR D activation trust, final formula):
652
+ *
653
+ * executable = USER trusted definitions ∩ USER-authorized ids ∩
654
+ * currently rendered layout ids
655
+ *
656
+ * The trusted definitions come from the USER-layer semantic read; the
657
+ * authorized ids come from the ConfigPort's mode-gated projection (a stale
658
+ * leftover USER layout under footer: default/compact authorizes nothing); the
659
+ * rendered layout is what the composer actually shows — a command hidden by
660
+ * the merged layout must not keep running in the background. A /footer save is
661
+ * the special case where the just-committed validated layout is both
662
+ * authorized and rendered. */
663
+ function executableCommandItemIds(trustedCommands, authorizedIds, renderedLayout) {
664
+ const trustedIds = new Set(trustedCommands.map((item) => item.id));
665
+ const renderedIds = activeFooterItemIds(renderedLayout);
666
+ const executable = /* @__PURE__ */ new Set();
667
+ for (const id of renderedIds) if (trustedIds.has(id) && authorizedIds.has(id)) executable.add(id);
668
+ return executable;
669
+ }
670
+ /** Escape regex metacharacters for a finder's regex pattern: the completion
671
+ * term is a LITERAL substring (the scoring model's input), so a filename
672
+ * containing `[`, `+`, `.` etc. must match as literal text, never as a regex.
673
+ * Both locality adapters pass the escaped term. */
674
+ function escapeFinderRegex(term) {
675
+ return term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
676
+ }
677
+ /**
678
+ * Normalize one finder's stdout into detached records: NUL records when the
679
+ * finder honoured `--print0` (so legal filenames containing newlines survive),
680
+ * the newline fallback for a fake/older finder (preserving meaningful leading
681
+ * and trailing spaces in a filename), the `./` prefix strip, the directory
682
+ * hint and the `.git` exclusion. PURE — operates solely on detached strings.
683
+ */
684
+ function normalizeFinderRecords(stdout) {
685
+ const records = stdout.includes("\0") ? stdout.split("\0").filter((record) => record !== "") : (() => {
686
+ const lines = stdout.split(/\r?\n/);
687
+ if (lines.at(-1) === "") lines.pop();
688
+ return lines.filter((line) => line !== "");
689
+ })();
690
+ const out = [];
691
+ for (const line of records) {
692
+ const relative = line.startsWith("./") ? line.slice(2) : line;
693
+ if (relative === ".git" || relative.startsWith(".git/") || relative.includes("/.git/")) continue;
694
+ out.push({
695
+ path: relative.endsWith("/") ? relative.slice(0, -1) : relative,
696
+ directoryHint: relative.endsWith("/")
697
+ });
698
+ }
699
+ return out;
700
+ }
701
+ /**
702
+ * The bounded recursive scan of one directory's SUBTREE: the root's direct
703
+ * children are always complete (a root-level `src/` must be found), deeper
704
+ * entries are capped at {@link MAX_FALLBACK_SCAN}. Paths are relative to
705
+ * `baseDir` (`sub/deep.ts`); `.git` is skipped (kimi parity); a symlinked
706
+ * directory is NEVER descended — at the search root and below it alike
707
+ * (cycle safety), so a link to a parent directory or to the workspace's
708
+ * outside cannot widen the scan or consume the traversal budget.
709
+ *
710
+ * ASYNC: the fuzzy fallback runs on the EDITOR path, so a synchronous
711
+ * whole-tree traversal would block the editor's event loop per keystroke. The
712
+ * scan yields to the event loop between directory levels and checks the
713
+ * AbortSignal inside the loop — the editor stays responsive and a cancelled
714
+ * request stops the traversal promptly.
715
+ */
716
+ async function scanSubtree(baseDir, driver, signal) {
717
+ const candidates = [];
718
+ const firstEntries = await driver.listDirectory(baseDir, "", signal);
719
+ if (signal.aborted || firstEntries === null) return [];
720
+ const stack = [];
721
+ for (const entry of firstEntries) {
722
+ if (entry.name === ".git") continue;
723
+ candidates.push({
724
+ path: entry.name,
725
+ kind: entry.kind
726
+ });
727
+ if (entry.descendable) stack.push(entry.name);
728
+ }
729
+ let scanned = 0;
730
+ while (stack.length > 0 && scanned < 2e3) {
731
+ if (signal.aborted) break;
732
+ const relativeDir = stack.pop() ?? "";
733
+ const entries = await driver.listDirectory(baseDir, relativeDir, signal);
734
+ if (entries === null) continue;
735
+ for (const entry of entries) {
736
+ if (signal.aborted || scanned >= 2e3) break;
737
+ if (entry.name === ".git") continue;
738
+ const path = `${relativeDir}/${entry.name}`;
739
+ scanned += 1;
740
+ candidates.push({
741
+ path,
742
+ kind: entry.kind
743
+ });
744
+ if (entry.kind === "directory" && entry.descendable) stack.push(path);
745
+ }
746
+ if (stack.length > 0) await new Promise((resolve) => setImmediate(resolve));
747
+ }
748
+ return signal.aborted ? [] : candidates;
749
+ }
750
+ /** Map one driver listing into path candidates, dropping `.git` (a `@` or
751
+ * `/image` listing never presents VCS internals). PURE. */
752
+ function listingCandidates(entries) {
753
+ return entries.filter((entry) => entry.name !== ".git").map((entry) => ({
754
+ path: entry.name,
755
+ kind: entry.kind
756
+ }));
757
+ }
758
+ /**
759
+ * Answer one resolved query with discovery facts: a scoped LISTING (empty
760
+ * term) reads the target directory's direct children; a scoped or whole-tree
761
+ * FUZZY query uses the locality finder when available and the bounded subtree
762
+ * scan otherwise. Paths are relative to the QUERY's search base.
763
+ * @param query - the resolved path query (searchBase already absolute).
764
+ * @param driver - the locality's discovery boundary.
765
+ * @param signal - the editor's request abort.
766
+ */
767
+ async function discoverForQuery(query, driver, signal) {
768
+ if (query.explicitScope && query.searchTerm === "") {
769
+ if (signal.aborted) return [];
770
+ const direct = await driver.listDirectory(query.searchBase, "", signal);
771
+ if (signal.aborted || direct === null) return [];
772
+ return listingCandidates(direct);
773
+ }
774
+ if (!query.winAbsolute && !query.raw.includes("\\")) {
775
+ const found = await driver.find(query.searchBase, query.searchTerm, signal);
776
+ if (signal.aborted) return [];
777
+ if (found !== null) {
778
+ const seen = new Set(found.map((candidate) => candidate.path));
779
+ const lowerTerm = query.searchTerm.toLowerCase();
780
+ const direct = await driver.listDirectory(query.searchBase, "", signal);
781
+ if (signal.aborted) return [];
782
+ const merged = (direct === null ? [] : listingCandidates(direct)).filter((candidate) => lowerTerm === "" || candidate.path.toLowerCase().includes(lowerTerm)).filter((candidate) => !seen.has(candidate.path));
783
+ return [...found, ...merged];
784
+ }
785
+ }
786
+ return scanSubtree(query.searchBase, driver, signal);
787
+ }
788
+ //#endregion
789
+ //#region src/domain/file-completion/query.ts
790
+ /**
791
+ * The neutral path query resolver (TS8-A, split out of the historical
792
+ * `src/file-completion/query.ts`): PURE — turns a raw token into the search
793
+ * directory, the basename term, the display prefix and whether the token
794
+ * names an EXPLICIT scope. No filesystem access, and no environment
795
+ * discovery: the facts this algebra needs arrive from the LOCALITY owner as
796
+ * an explicit {@link PathQueryEnvironment}, never from `homedir()` /
797
+ * `process.platform` inside the domain layer.
798
+ *
799
+ * A scoped token (`src/fo`, `../foo`, `../../foo`, `~/foo`, `/tmp/foo`,
800
+ * a drive-qualified path, or an UNC path) searches ONLY its resolved
801
+ * directory — never a whole-tree scan filtered by string. An unscoped token
802
+ * (`foo`, `nested`, `config`) is a whole-tree fuzzy query with an EMPTY
803
+ * display base.
804
+ * @module @xmoon76/dsh-pi-tui/domain/file-completion/query
805
+ */
806
+ /**
807
+ * The HOST filesystem's path algebra, selected from the EXPLICIT
808
+ * `windowsHost` fact. Every host-native path this module computes (a joined
809
+ * scope, an expanded `~`) goes through this api, so the domain never infers
810
+ * the running machine from the ambient `node:path` default export: a caller
811
+ * that injects a foreign host's facts gets that host's algebra.
812
+ */
813
+ function hostPathApi(windowsHost) {
814
+ return windowsHost ? win32 : posix;
815
+ }
816
+ /**
817
+ * Return the final path component without depending on the host OS dialect.
818
+ * Completion can present a Windows path while the client process is running
819
+ * on POSIX (and vice versa), so `node:path.basename` is not sufficient here.
820
+ */
821
+ function basenameOfPath(path) {
822
+ const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
823
+ return path.slice(separator + 1);
824
+ }
825
+ /**
826
+ * Expand a leading `~` in one token against the locality's home directory
827
+ * (other tokens unchanged). PURE — both the home directory AND the host
828
+ * platform are explicit facts: the joined result is a path on the HOST
829
+ * filesystem, so it uses that host's algebra.
830
+ * @param token - the raw token.
831
+ * @param homeDir - the locality owner's home directory fact.
832
+ * @param windowsHost - whether the filesystem host is Windows.
833
+ */
834
+ function expandHomeToken(token, homeDir, windowsHost) {
835
+ if (token === "~") return homeDir;
836
+ if (token.startsWith("~/") || token.startsWith("~\\")) return hostPathApi(windowsHost).join(homeDir, token.slice(2).replace(/\\/g, "/"));
837
+ return token;
838
+ }
839
+ /**
840
+ * Choose the separator immediately before the basename. This preserves a
841
+ * mixed Windows token's actual dialect (forward-slash drive paths stay
842
+ * forward-slashed, while backslash drive paths stay backslashed) instead of
843
+ * normalizing it to the host platform's preferred separator.
844
+ */
845
+ function separatorOfRaw(raw, windowsDialect) {
846
+ const slash = raw.lastIndexOf("/");
847
+ const backslash = raw.lastIndexOf("\\");
848
+ if (slash === -1 && backslash === -1) return windowsDialect ? "\\" : "/";
849
+ return backslash > slash ? "\\" : "/";
850
+ }
851
+ /** Whether the token is a leading-home form, including `~\\foo`. */
852
+ function isHomeToken$1(raw) {
853
+ return raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\");
854
+ }
855
+ /** Whether one raw token is genuinely Windows-dialect. */
856
+ function isWindowsDialect(raw, winAbsolute) {
857
+ return winAbsolute || raw.includes("\\");
858
+ }
859
+ /** Whether an absolute path uses the Windows drive/UNC grammar. */
860
+ function isWindowsAbsolute(raw) {
861
+ return !raw.startsWith("/") && win32.isAbsolute(raw);
862
+ }
863
+ /** Join a relative scope while keeping POSIX fixtures usable for a
864
+ * Windows-looking token. The result is a path on the HOST filesystem, so the
865
+ * joiner comes from the EXPLICIT host fact (never `process.platform`); the
866
+ * TOKEN's own dialect only decides whether backslashes are the user's
867
+ * separators or ordinary characters.
868
+ *
869
+ * A `windowsDialect` token on a POSIX host has backslashes in the USER'S
870
+ * dialect, not as filesystem separators, so they are normalized; an
871
+ * unmistakable drive/UNC `cwd` selects the win32 joiner even on a POSIX host
872
+ * (that cwd string is itself a Windows path). */
873
+ function joinRelativeScope(cwd, rawPath, windowsDialect, windowsHost) {
874
+ const api = hostPathApi(windowsHost);
875
+ if (!windowsDialect) return api.join(cwd, rawPath);
876
+ const windowsCwd = /^[A-Za-z]:[\\/]/.test(cwd) || cwd.startsWith("\\\\");
877
+ if (windowsHost || windowsCwd) return win32.join(cwd, rawPath);
878
+ return api.join(cwd, rawPath.replace(/\\/g, "/"));
879
+ }
880
+ /** Whether the raw token names a directory that should be LISTED
881
+ * (`searchTerm === ''`). `.`/`..` root forms list their own relative
882
+ * directory; `./`/`../`/`~`/`/` list the scope root. */
883
+ function isBareScopeForm(raw) {
884
+ return raw === "" || raw === "." || raw === ".." || raw === "./" || raw === "../" || raw === "~" || raw === "~/" || raw === "~\\" || raw === "/" || raw === ".\\" || raw === "..\\";
885
+ }
886
+ /**
887
+ * Resolve ONE raw completion token into the pure path query.
888
+ * @param raw - the token as typed (quotes stripped; leading separator
889
+ * whitespace stripped by the caller).
890
+ * @param cwd - the completion base (the Client cwd for local completion, the
891
+ * Direct workspace cwd for the WORKSPACE compatibility scanner; relative
892
+ * forms resolve against it).
893
+ * @param environment - the locality owner's explicit environment facts.
894
+ */
895
+ function resolvePathQuery(raw, cwd, environment) {
896
+ const expanded = expandHomeToken(raw, environment.homeDir, environment.windowsHost);
897
+ const posixAbsolute = raw.startsWith("/");
898
+ const winAbsolute = isWindowsAbsolute(raw);
899
+ const windowsDialect = isWindowsDialect(raw, winAbsolute);
900
+ const absolute = isHomeToken$1(raw) || posixAbsolute || winAbsolute;
901
+ const tokenWindows = windowsDialect || environment.windowsHost;
902
+ const pathDirname = tokenWindows ? win32.dirname : posix.dirname;
903
+ const pathBasename = tokenWindows ? win32.basename : posix.basename;
904
+ const separator = separatorOfRaw(raw, windowsDialect);
905
+ if (isBareScopeForm(raw)) return {
906
+ raw,
907
+ searchBase: raw === "." || raw === ".." || raw === "./" || raw === "../" || raw === ".\\" || raw === "..\\" ? joinRelativeScope(cwd, raw, windowsDialect, environment.windowsHost) : absolute ? expanded : joinRelativeScope(cwd, expanded, windowsDialect, environment.windowsHost),
908
+ searchTerm: "",
909
+ displayBase: raw === "" ? "" : raw === "~" ? `~${separator}` : raw.endsWith("/") || raw.endsWith("\\") ? raw : `${raw}${separator}`,
910
+ explicitScope: true,
911
+ winAbsolute
912
+ };
913
+ if (raw.endsWith("/") || raw.endsWith("\\")) return {
914
+ raw,
915
+ searchBase: absolute ? expanded : joinRelativeScope(cwd, expanded, windowsDialect, environment.windowsHost),
916
+ searchTerm: "",
917
+ displayBase: raw,
918
+ explicitScope: true,
919
+ winAbsolute
920
+ };
921
+ const lastSlash = Math.max(raw.lastIndexOf("/"), raw.lastIndexOf("\\"));
922
+ const hasSeparator = lastSlash >= 0;
923
+ const displayBase = hasSeparator ? raw.slice(0, lastSlash + 1) : "";
924
+ const rawDir = hasSeparator ? raw.slice(0, lastSlash) : "";
925
+ const searchTerm = pathBasename(expanded);
926
+ const explicitScope = hasSeparator || absolute;
927
+ const searchBaseForScope = isHomeToken$1(raw) ? expandHomeToken(rawDir, environment.homeDir, environment.windowsHost) : absolute ? pathDirname(expanded) : joinRelativeScope(cwd, rawDir, windowsDialect, environment.windowsHost);
928
+ return {
929
+ raw,
930
+ searchBase: explicitScope ? searchBaseForScope : cwd,
931
+ searchTerm,
932
+ displayBase,
933
+ explicitScope,
934
+ winAbsolute
935
+ };
936
+ }
937
+ /**
938
+ * Reattach the query's display base onto one discovered candidate path.
939
+ * Discovery returns paths RELATIVE to the query's search base; the candidate
940
+ * the user accepts must read in the USER'S dialect (`../sibling-file.ts`,
941
+ * `~/pics/a.png`, `src/deep.ts`, `/tmp/x`). The SOURCE calls this before its
942
+ * candidates cross the port contract (the port's paths are user-facing).
943
+ * PURE.
944
+ */
945
+ function reattachDisplayBase(candidate, query) {
946
+ if (query.displayBase === "") return candidate;
947
+ return {
948
+ ...candidate,
949
+ path: `${query.displayBase}${candidate.path}`
950
+ };
951
+ }
952
+ /** Score one candidate against the query term (lowercased). */
953
+ function scorePathCandidate(candidate, lowerQuery) {
954
+ if (lowerQuery === "") {
955
+ const depthPenalty = candidate.path.split(/[\\/]/).length - 1;
956
+ return (candidate.kind === "directory" ? 120 : 100) - depthPenalty;
957
+ }
958
+ const lowerPath = candidate.path.toLowerCase();
959
+ const lowerBase = basenameOfPath(candidate.path).toLowerCase();
960
+ let score = 0;
961
+ if (lowerBase === lowerQuery) score = 100;
962
+ else if (lowerBase.startsWith(lowerQuery)) score = 80;
963
+ else if (lowerBase.includes(lowerQuery)) score = 50;
964
+ else if (lowerPath.includes(lowerQuery)) score = 30;
965
+ if (candidate.kind === "directory" && score > 0) score += 10;
966
+ return score;
967
+ }
968
+ /** Deterministic order: score desc, directories (kind) first, path asc. */
969
+ function compareScoredPaths(left, right) {
970
+ if (left.score !== right.score) return right.score - left.score;
971
+ if (left.candidate.kind !== right.candidate.kind) return left.candidate.kind === "directory" ? -1 : 1;
972
+ return left.candidate.path.localeCompare(right.candidate.path);
973
+ }
974
+ /**
975
+ * Rank, filter and bound one discovery set (the local scoring model — the
976
+ * SOURCE's own order for its Client-fs / Direct-workspace-compat consumers,
977
+ * NOT the live Session `@` mention path, whose ranking is the official Host
978
+ * authority's). PURE: path candidates in, ranked path candidates out — no UI
979
+ * DTOs, so a semantic adapter can own its ranking end-to-end.
980
+ */
981
+ function rankPathCandidates(candidates, term) {
982
+ const lowerQuery = term.toLowerCase();
983
+ return candidates.map((candidate) => ({
984
+ candidate,
985
+ score: scorePathCandidate(candidate, lowerQuery)
986
+ })).filter((entry) => entry.score > 0).sort(compareScoredPaths).slice(0, 50).map((entry) => entry.candidate);
987
+ }
988
+ //#endregion
989
+ //#region src/runtime/read-error.ts
990
+ /**
991
+ * A typed semantic READ failure: the read was superseded by a connection or
992
+ * surface change, so its result must not be presented as the current Host
993
+ * state. Remote adapters throw this instead of a generic Error so a consumer
994
+ * can stay UI-silent (v2 §0.2.3/§0.3.1) rather than surfacing a stale failure.
995
+ * Host-free; no I/O.
996
+ * @module @xmoon76/dsh-pi-tui/read-error
997
+ */
998
+ /** The read no longer owns the surface (generation change or superseded epoch). */
999
+ var SupersededReadError = class extends Error {
1000
+ reason;
1001
+ constructor(reason) {
1002
+ super(reason);
1003
+ this.name = "SupersededReadError";
1004
+ this.reason = reason;
1005
+ }
1006
+ };
1007
+ //#endregion
1008
+ //#region src/domain/catalog/provider.ts
1009
+ /**
1010
+ * Read a value at a dotted path inside a plain object (the web schema-form
1011
+ * `getPath` semantics, structural here so /login needs no schema package).
1012
+ * @param value - the object to walk.
1013
+ * @param path - path segments; the empty path returns `value` itself.
1014
+ * @returns the value at the path, or undefined when any segment is missing.
1015
+ */
1016
+ function getPath(value, path) {
1017
+ let current = value;
1018
+ for (const key of path) {
1019
+ if (Array.isArray(current)) {
1020
+ current = current[Number(key)];
1021
+ continue;
1022
+ }
1023
+ if (typeof current !== "object" || current === null) return void 0;
1024
+ current = current[key];
1025
+ }
1026
+ return current;
1027
+ }
1028
+ /**
1029
+ * Derive the conventional credential reference for a provider route: the web
1030
+ * Models page never asks for an environment-variable name, so a typed key
1031
+ * stores under this derived reference and the profile records it as
1032
+ * `apiKeyEnv`. Kept byte-identical to the web's `deriveKeyRef`.
1033
+ * @param provider - provider route id (e.g. `anthropic`, `minimax-cn`).
1034
+ * @returns the derived reference name (e.g. `MINIMAX_CN_API_KEY`).
1035
+ */
1036
+ function deriveKeyRef(provider) {
1037
+ return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
1038
+ }
1039
+ /**
1040
+ * A route id usable as a settings key AND as the stem of a credential name.
1041
+ * The web create card's pattern: the leading letter is the second half of
1042
+ * that — `deriveKeyRef` uppercases the id and replaces every non-alphanumeric
1043
+ * run with `_`, and a credential reference is a POSIX shell identifier, which
1044
+ * cannot start with a digit. A digit-leading id passes every check this card
1045
+ * makes and then fails at the credential seam with a raw regular expression
1046
+ * the user cannot act on.
1047
+ */
1048
+ const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
1049
+ /** The wire protocols a hand-declared route may name. Mirrors the pi-ai
1050
+ * adapter's `supportedProtocols()`; keep in step with
1051
+ * `@deepseek-ai/dsh-llm-pi-ai`'s `PROTOCOLS` table (the drift gate is the
1052
+ * adapter's own — a protocol it stops serving must be removed here too). */
1053
+ const PROTOCOL_CHOICES = [
1054
+ "openai-completions",
1055
+ "openai-responses",
1056
+ "anthropic-messages"
1057
+ ];
1058
+ /**
1059
+ * Merge the configurable-provider directory with the existing settings
1060
+ * section into the /login option list.
1061
+ *
1062
+ * @param entries - the llm directory entries (or undefined when the llm
1063
+ * service is absent — degrades to the official target only).
1064
+ * @param readSection - read one settings namespace's resolved section (the
1065
+ * settings service's `get`); absent namespaces read as undefined.
1066
+ * @returns the merged options, deepseek official first, deduped by ref.
1067
+ */
1068
+ function providerOptionsFor(entries, readSection) {
1069
+ const options = [];
1070
+ const seen = /* @__PURE__ */ new Set();
1071
+ const push = (option) => {
1072
+ if (seen.has(option.ref)) return;
1073
+ seen.add(option.ref);
1074
+ options.push(option);
1075
+ };
1076
+ push({
1077
+ label: "deepseek official",
1078
+ ref: "DEEPSEEK_API_KEY",
1079
+ route: "deepseek-official",
1080
+ configured: true,
1081
+ declared: false,
1082
+ namesCredential: true,
1083
+ group: "configured",
1084
+ settingsNs: "",
1085
+ settingsPath: []
1086
+ });
1087
+ if (entries === void 0) return options;
1088
+ for (const entry of entries) {
1089
+ if (entry.provider.length === 0 || entry.settingsNs.length === 0) continue;
1090
+ const section = readSection(entry.settingsNs);
1091
+ const profile = entry.settingsPath.length === 0 ? section : getPath(section, entry.settingsPath);
1092
+ const configured = profile !== void 0;
1093
+ const namedRef = configured && typeof profile === "object" && profile !== null && typeof profile.apiKeyEnv === "string" ? profile.apiKeyEnv : void 0;
1094
+ const namesCredential = namedRef !== void 0 && namedRef !== "";
1095
+ const ref = namesCredential ? namedRef : deriveKeyRef(entry.provider);
1096
+ push({
1097
+ label: entry.displayName,
1098
+ ref,
1099
+ route: entry.provider,
1100
+ configured,
1101
+ declared: entry.declared === true,
1102
+ namesCredential,
1103
+ group: configured ? "configured" : entry.declared === true ? "custom" : "available",
1104
+ settingsNs: entry.settingsNs,
1105
+ settingsPath: [...entry.settingsPath]
1106
+ });
1107
+ }
1108
+ return options;
1109
+ }
1110
+ /** The credential-target options for /login and /logout: the deepseek
1111
+ * official ref always first, then every llm-pi-ai provider route's
1112
+ * apiKeyEnv (deduped by ref). `providers` is the llm-pi-ai settings
1113
+ * section's `providers` dict, or undefined when the adapter or the settings
1114
+ * service is absent — which degrades /login to the official target only.
1115
+ * Kept as the settings-only fallback when the llm directory is absent. */
1116
+ function credentialOptionsFor(providers) {
1117
+ const options = [{
1118
+ label: "deepseek official",
1119
+ ref: "DEEPSEEK_API_KEY"
1120
+ }];
1121
+ if (providers === void 0) return options;
1122
+ const seen = new Set(options.map((option) => option.ref));
1123
+ for (const [route, profile] of Object.entries(providers)) {
1124
+ const ref = profile?.apiKeyEnv;
1125
+ if (ref === void 0 || ref === "" || seen.has(ref)) continue;
1126
+ seen.add(ref);
1127
+ options.push({
1128
+ label: route,
1129
+ ref
1130
+ });
1131
+ }
1132
+ return options;
1133
+ }
1134
+ /** Resolve a /login or /logout argument to a credential ref. A
1135
+ * case-insensitive llm-pi-ai route name wins, then a literal env-var-looking
1136
+ * name (any casing, uppercased like the old behavior — `/login my_key` sets
1137
+ * `MY_KEY`); anything else is unknown and returns undefined so the caller
1138
+ * can list the valid options. */
1139
+ function resolveCredentialArg(arg, options) {
1140
+ const trimmed = arg.trim();
1141
+ if (trimmed === "") return void 0;
1142
+ const needle = trimmed.toLowerCase();
1143
+ const route = options.find((option) => option.label.toLowerCase() === needle || option.label.split(" ")[0].toLowerCase() === needle);
1144
+ if (route !== void 0) return route.ref;
1145
+ if (/^[a-zA-Z][a-zA-Z0-9_]*$/.test(trimmed)) return trimmed.toUpperCase();
1146
+ }
1147
+ //#endregion
1148
+ //#region src/runtime/session-lifecycle-port.ts
1149
+ /** A lifecycle outcome that must ABORT the caller's transition, carrying the
1150
+ * two independent axes so a Remote caller never has to parse a string. */
1151
+ var LifecycleError = class extends Error {
1152
+ settlement;
1153
+ ownership;
1154
+ publishedSessionId;
1155
+ /** CORRELATION ONLY for an indeterminate create — never publication proof. */
1156
+ requestedSessionId;
1157
+ constructor(settlement, ownership, message, publishedSessionId, requestedSessionId) {
1158
+ super(message);
1159
+ this.name = "LifecycleError";
1160
+ this.settlement = settlement;
1161
+ this.ownership = ownership;
1162
+ this.publishedSessionId = publishedSessionId;
1163
+ this.requestedSessionId = requestedSessionId;
1164
+ }
1165
+ };
1166
+ /** Unwrap a CREATE result for the runner's transition: a `created` result that
1167
+ * still owns the surface yields the handle; everything else aborts with a
1168
+ * machine-readable `LifecycleError` (a superseded create is NOT committed to
1169
+ * the local surface, though its identity stays available). */
1170
+ function requireCreated(result) {
1171
+ const { ownership, outcome } = result;
1172
+ switch (outcome.kind) {
1173
+ case "created":
1174
+ if (ownership === "superseded") throw new LifecycleError("superseded", ownership, `Session "${outcome.handle.session.id}" was created, but the local surface was superseded — do not retry creation`, outcome.handle.session.id);
1175
+ return outcome.handle;
1176
+ case "published-with-error": throw new LifecycleError(outcome.kind, ownership, `Session "${outcome.sessionId}" was published, but workspace attach/reconcile failed: ${outcome.error.message} (${outcome.error.code}) — do not retry creation`, outcome.sessionId);
1177
+ case "indeterminate": throw new LifecycleError(outcome.kind, ownership, `the create is indeterminate — do not retry: ${outcome.error.message} (${outcome.error.code})`, void 0, outcome.requestedSessionId);
1178
+ case "rejected": throw new LifecycleError(outcome.kind, ownership, `${outcome.error.message} (${outcome.error.code})`, void 0);
1179
+ case "cancelled": throw new LifecycleError("cancelled", ownership, "the Session creation was cancelled before dispatch", void 0);
1180
+ }
1181
+ }
1182
+ /** Unwrap an OPEN result for the runner's transition. */
1183
+ function requireOpened(result) {
1184
+ const { ownership, outcome } = result;
1185
+ switch (outcome.kind) {
1186
+ case "opened":
1187
+ if (ownership === "superseded") throw new LifecycleError("superseded", ownership, "the Session was opened but the local surface was superseded", outcome.handle.session.id);
1188
+ return outcome.handle;
1189
+ case "unavailable": throw new LifecycleError("unavailable", ownership, outcome.message, void 0);
1190
+ case "cancelled": throw new LifecycleError("cancelled", ownership, "the Session open was cancelled before selection", void 0);
1191
+ }
1192
+ }
1193
+ /** Extract the Direct ownership handle (the real AgentHandle with
1194
+ * `dispose()`) from a lifecycle result. Accepts both the converged
1195
+ * SessionHandle and a legacy AgentHandle so transition code cannot lose the
1196
+ * ownership capability. Remote handles lack `direct` and yield undefined. */
1197
+ function ownerHandleOf(next) {
1198
+ const handle = next;
1199
+ if (handle.direct?.ownerHandle !== void 0) return handle.direct.ownerHandle;
1200
+ if (typeof handle.dispose === "function") return handle;
1201
+ }
1202
+ /** Extract the live in-process agent from a lifecycle result (the Direct
1203
+ * SessionHandle via `direct.agent`, or a legacy AgentHandle's `agent`).
1204
+ * Remote handles yield undefined. */
1205
+ function directAgentOf(next) {
1206
+ const handle = next;
1207
+ return handle.direct?.agent ?? handle.agent;
1208
+ }
1209
+ /** Extract the Remote Client generation owner from a lifecycle result. A
1210
+ * Direct handle yields undefined. A composing Remote caller must release it
1211
+ * exactly once on retirement — including when a superseded transition discards
1212
+ * the handle. The Remote session-owner provider (`app/remote/session-owners.ts`)
1213
+ * consumes it as the sole ownership mapping source; the Direct runner never
1214
+ * interprets it. */
1215
+ function clientOwnerOf(next) {
1216
+ return next.client;
1217
+ }
1218
+ //#endregion
1219
+ //#region src/runtime/capability.ts
1220
+ /** The capabilities the experimental Remote backend serves: exactly the
1221
+ * ports its M3-3B assembly provides. A capability is added HERE only when its
1222
+ * Remote port genuinely serves the semantic contract (no misleading empty
1223
+ * defaults), so the set is deliberately explicit rather than derived. */
1224
+ const REMOTE_IMPLEMENTED_CAPABILITIES = [
1225
+ "session-read",
1226
+ "session-write",
1227
+ "session-lifecycle",
1228
+ "subagent",
1229
+ "interaction",
1230
+ "catalog",
1231
+ "config",
1232
+ "host-file",
1233
+ "session-archive",
1234
+ "host-command",
1235
+ "plugin-manager",
1236
+ "job-observation"
1237
+ ];
1238
+ /** The capabilities the Direct backend ACTUALLY serves today: one port per
1239
+ * entry, nothing more — never advertise what is not served. */
1240
+ const DIRECT_IMPLEMENTED_CAPABILITIES = [
1241
+ "session-read",
1242
+ "session-write",
1243
+ "session-lifecycle",
1244
+ "subagent",
1245
+ "interaction",
1246
+ "catalog",
1247
+ "config",
1248
+ "host-file",
1249
+ "session-archive",
1250
+ "host-command",
1251
+ "plugin-manager",
1252
+ "job-observation"
1253
+ ];
1254
+ //#endregion
1255
+ //#region src/runtime/write-outcome.ts
1256
+ /**
1257
+ * Pinned Gateway infrastructure codes raised strictly BEFORE the addressed
1258
+ * business method is invoked (descriptor/argument/receiver/endpoint resolution
1259
+ * and context/lookup provider resolution — all inside `prepareInvocation` and
1260
+ * boundary decoding): such a failure proves the operation did not commit. The
1261
+ * only Gateway code raised AFTER invocation is `gateway/result-invalid`, so it
1262
+ * is deliberately absent.
1263
+ */
1264
+ const GATEWAY_PRE_INVOCATION_CODES = /* @__PURE__ */ new Set([
1265
+ "gateway/ambiguous-endpoint",
1266
+ "gateway/arguments-invalid",
1267
+ "gateway/binding-invalid",
1268
+ "gateway/context-failed",
1269
+ "gateway/context-not-found",
1270
+ "gateway/context-unavailable",
1271
+ "gateway/definition-unavailable",
1272
+ "gateway/input-invalid",
1273
+ "gateway/invocation-unavailable",
1274
+ "gateway/lookup-failed",
1275
+ "gateway/lookup-not-found",
1276
+ "gateway/lookup-unavailable",
1277
+ "gateway/method-unavailable",
1278
+ "gateway/provider-mismatch",
1279
+ "gateway/service-unavailable",
1280
+ "gateway/signature-invalid"
1281
+ ]);
1282
+ /**
1283
+ * Whether a failure code PROVES the addressed operation was refused: domain
1284
+ * codes, `gateway/bad-request`, and the pinned pre-invocation Gateway
1285
+ * infrastructure codes qualify. `gateway/cancelled`, `gateway/internal`,
1286
+ * `gateway/result-invalid`, an unknown `gateway/*` code, or a code-less failure
1287
+ * do NOT (the commit state stays unproven).
1288
+ *
1289
+ * SCOPE: this predicate expresses ONLY the D2.2-verified write vocabulary plus
1290
+ * the pinned Gateway pre-invocation codes. It is NOT a general
1291
+ * idempotency/commit judgement: a domain code does not universally prove that
1292
+ * no mutation happened (e.g. `session/workspace-attach-failed` can follow a
1293
+ * successful Session create/fork). Later operation families (D2.3/D2.4) must
1294
+ * use their own operation-specific settlement table rather than extending this
1295
+ * helper.
1296
+ */
1297
+ function isRemoteBusinessRefusalCode(code) {
1298
+ if (code === void 0) return false;
1299
+ if (code === "gateway/bad-request") return true;
1300
+ if (code.startsWith("gateway/")) return GATEWAY_PRE_INVOCATION_CODES.has(code);
1301
+ return true;
1302
+ }
1303
+ //#endregion
1304
+ //#region src/runtime/subagent-outcome.ts
1305
+ /**
1306
+ * Backend-neutral outcome/error classification for one subagent viewer prompt
1307
+ * (TS8-F4): the stable refusal taxonomy shared by the Direct and Remote
1308
+ * adapters. Pure and never throwing — it reads structural `code` values only,
1309
+ * so the subagent package stays resolvable from the dsh installation and is
1310
+ * never imported here.
1311
+ * @module @xmoon76/dsh-pi-tui/runtime/subagent-outcome
1312
+ */
1313
+ /**
1314
+ * Settle a failed viewer prompt. A caller cancellation and every PROVEN refusal
1315
+ * code are rejected (a domain `subagent/*` admission refusal, `gateway/bad-request`,
1316
+ * or a pre-invocation Gateway infrastructure code); only a carrier/internal/
1317
+ * post-invocation failure (`gateway/internal`, `gateway/result-invalid`, an
1318
+ * unknown `gateway/*` code) or a code-less throw settles `indeterminate`.
1319
+ */
1320
+ function classifySubagentPromptSettlement(error) {
1321
+ const reason = classifySubagentPromptError(error);
1322
+ if (reason.kind !== "error") return {
1323
+ kind: "rejected",
1324
+ reason
1325
+ };
1326
+ if (isRemoteBusinessRefusalCode(remoteErrorCode(error))) return {
1327
+ kind: "rejected",
1328
+ reason
1329
+ };
1330
+ return {
1331
+ kind: "indeterminate",
1332
+ message: reason.message
1333
+ };
1334
+ }
1335
+ /**
1336
+ * Classify an official prompt failure into the stable reason set. The Direct
1337
+ * adapter reads the official RemoteError vocabulary structurally (the `code`
1338
+ * string; the subagent package stays resolvable from the dsh installation,
1339
+ * never an import here).
1340
+ */
1341
+ function classifySubagentPromptError(error) {
1342
+ if (isAbortError(error)) return { kind: "cancelled" };
1343
+ const code = remoteErrorCode(error);
1344
+ if (code === "gateway/cancelled") return { kind: "cancelled" };
1345
+ if (code === "subagent/parent-unavailable") return { kind: "parent-unavailable" };
1346
+ if (code === "subagent/not-resumable" || code === "subagent/not-found" || code === "subagent/catalog-diagnostic") return { kind: "stale-child" };
1347
+ if (code === "subagent/unauthorized") return { kind: "unauthorized" };
1348
+ if (code === "subagent/delivery-unavailable" || code === "subagent/projections-unavailable") return { kind: "unavailable" };
1349
+ return {
1350
+ kind: "error",
1351
+ message: safeSubagentErrorMessage(error)
1352
+ };
1353
+ }
1354
+ /** The stable failure code of a structural official failure, if any. */
1355
+ function remoteErrorCode(error) {
1356
+ if (typeof error !== "object" || error === null) return void 0;
1357
+ const code = error.code;
1358
+ return typeof code === "string" && code !== "" ? code : void 0;
1359
+ }
1360
+ function isAbortError(error) {
1361
+ return typeof error === "object" && error !== null && error.name === "AbortError";
1362
+ }
1363
+ /** One-line safe error text (never throws on hostile values). Shared with the
1364
+ * Direct adapter's pre-dispatch catch so both settle the same wording. */
1365
+ function safeSubagentErrorMessage(error) {
1366
+ if (error === void 0 || error === null) return "unknown error";
1367
+ if (typeof error === "string") return error;
1368
+ if (error instanceof Error) {
1369
+ const message = error.message;
1370
+ return message === "" ? error.name : message;
1371
+ }
1372
+ if (typeof error === "object") {
1373
+ const message = error.message;
1374
+ if (typeof message === "string" && message !== "") return message;
1375
+ }
1376
+ try {
1377
+ return String(error);
1378
+ } catch {
1379
+ return "unknown error";
1380
+ }
1381
+ }
1382
+ //#endregion
1383
+ //#region src/runtime/presentation-read-port.ts
1384
+ /** Detach the official `turnOutline` wire view into the picker DTO — only
1385
+ * well-formed entries cross (shared by the Direct projection read and the
1386
+ * Remote binding projection read); a foreign-shaped value reads unknown. */
1387
+ function detachedTurnOutline(value) {
1388
+ if (!Array.isArray(value)) return void 0;
1389
+ const entries = [];
1390
+ for (const entry of value) {
1391
+ if (typeof entry !== "object" || entry === null) return void 0;
1392
+ const record = entry;
1393
+ if (typeof record.turn !== "number" || typeof record.seq !== "number") return void 0;
1394
+ if (typeof record.prompt !== "string" || typeof record.response !== "string") return void 0;
1395
+ entries.push({
1396
+ turn: record.turn,
1397
+ seq: record.seq,
1398
+ prompt: record.prompt,
1399
+ response: record.response
1400
+ });
1401
+ }
1402
+ return Object.freeze(entries);
1403
+ }
1404
+ //#endregion
1405
+ //#region src/runtime/session-status-projection.ts
1406
+ /**
1407
+ * Detach the official projection values into the status DTO. Fields are
1408
+ * only present when their official projection value is well-formed; nothing
1409
+ * is coerced, defaulted or copied from another subject.
1410
+ */
1411
+ function detachedSessionStatus(sessionId, values, cwd) {
1412
+ const record = { sessionId };
1413
+ if (typeof cwd === "string" && cwd !== "") record.cwd = cwd;
1414
+ const model = modelSelectionFact(values.modelSelection);
1415
+ if (model !== void 0) record.model = model;
1416
+ if (typeof values.agentPreset === "string" && values.agentPreset !== "") record.preset = values.agentPreset;
1417
+ if (typeof values.title === "string" && values.title !== "") record.title = values.title;
1418
+ const permissionValue = values.permissions;
1419
+ if (typeof permissionValue === "object" && permissionValue !== null) {
1420
+ const current = permissionValue.currentValue;
1421
+ if (typeof current === "string" && current !== "") record.permission = current;
1422
+ }
1423
+ if ("goal" in values) {
1424
+ const goal = goalFact(values.goal);
1425
+ if (goal !== void 0) record.goal = goal;
1426
+ }
1427
+ const pressure = numericRecord(values.contextPressure);
1428
+ const breakdownValue = values.contextBreakdown;
1429
+ const breakdown = typeof breakdownValue === "object" && breakdownValue !== null ? numericRecord(breakdownValue) : void 0;
1430
+ const hasBreakdown = breakdown !== void 0 && typeof breakdown.systemTokens === "number" && typeof breakdown.toolsTokens === "number" && typeof breakdown.messageTokens === "number";
1431
+ if (pressure !== void 0 && Object.keys(pressure).length > 0 || hasBreakdown) record.context = {
1432
+ ...pressure ?? {},
1433
+ ...hasBreakdown ? { breakdown: {
1434
+ systemTokens: breakdown.systemTokens,
1435
+ toolsTokens: breakdown.toolsTokens,
1436
+ messageTokens: breakdown.messageTokens
1437
+ } } : {}
1438
+ };
1439
+ const todos = detachedTodos(values.todos);
1440
+ if (todos !== void 0) record.todos = todos;
1441
+ const usageValue = numericRecord(values.tokenUsage);
1442
+ if (usageValue !== void 0 && typeof usageValue.uncachedInputTokens === "number" && typeof usageValue.outputTokens === "number" && typeof usageValue.cacheReadTokens === "number" && typeof usageValue.cacheWriteTokens === "number") record.usage = {
1443
+ uncachedInputTokens: usageValue.uncachedInputTokens,
1444
+ outputTokens: usageValue.outputTokens,
1445
+ cacheReadTokens: usageValue.cacheReadTokens,
1446
+ cacheWriteTokens: usageValue.cacheWriteTokens
1447
+ };
1448
+ return record;
1449
+ }
1450
+ /** Narrow the official `goal` projection view: `null` (no goal) is a LEGAL
1451
+ * value and stays distinct from `undefined` (unavailable); the current goal
1452
+ * is read from `goal.{objective,phase}` with the phase vocabulary validated. */
1453
+ function goalFact(value) {
1454
+ if (value === null) return null;
1455
+ if (typeof value !== "object") return void 0;
1456
+ const current = value.goal;
1457
+ if (typeof current !== "object" || current === null) return void 0;
1458
+ const goal = current;
1459
+ if (typeof goal.objective !== "string" || goal.objective === "") return void 0;
1460
+ if (goal.phase !== "active" && goal.phase !== "paused" && goal.phase !== "blocked" && goal.phase !== "complete") return;
1461
+ return {
1462
+ objective: goal.objective,
1463
+ phase: goal.phase
1464
+ };
1465
+ }
1466
+ /** Narrow an unknown projection value to its numeric fields (no coercion). */
1467
+ function numericRecord(value) {
1468
+ if (typeof value !== "object" || value === null) return void 0;
1469
+ const source = value;
1470
+ const out = {};
1471
+ for (const [key, entry] of Object.entries(source)) if (typeof entry === "number") out[key] = entry;
1472
+ return out;
1473
+ }
1474
+ /** The effective selection off the official `modelSelection` fold:
1475
+ * `next ?? lastUsed`, validated to the normalized shape. */
1476
+ function modelSelectionFact(value) {
1477
+ if (typeof value !== "object" || value === null) return void 0;
1478
+ const fold = value;
1479
+ for (const candidate of [fold.next, fold.lastUsed]) {
1480
+ if (typeof candidate !== "object" || candidate === null) continue;
1481
+ const selection = candidate;
1482
+ if (typeof selection.provider !== "string" || selection.provider === "") continue;
1483
+ if (typeof selection.model !== "string" || selection.model === "") continue;
1484
+ return {
1485
+ provider: selection.provider,
1486
+ model: selection.model,
1487
+ ...typeof selection.reasoningEffort === "string" && selection.reasoningEffort !== "" ? { reasoningEffort: selection.reasoningEffort } : {}
1488
+ };
1489
+ }
1490
+ }
1491
+ /** Detach the official `todos` whole-list snapshot. The projection's
1492
+ * LEGAL `null` (no `todo/write` yet) stays `null`; only `undefined` (the
1493
+ * projection value was absent — capability unavailable) reads absent. */
1494
+ function detachedTodos(value) {
1495
+ if (value === null) return null;
1496
+ if (value === void 0) return void 0;
1497
+ if (!Array.isArray(value)) return void 0;
1498
+ const todos = [];
1499
+ for (const item of value) {
1500
+ if (typeof item !== "object" || item === null) return void 0;
1501
+ const todo = item;
1502
+ if (typeof todo.content !== "string") return void 0;
1503
+ if (todo.status !== "pending" && todo.status !== "in_progress" && todo.status !== "completed") return void 0;
1504
+ todos.push({
1505
+ content: todo.content,
1506
+ status: todo.status
1507
+ });
1508
+ }
1509
+ return Object.freeze(todos);
1510
+ }
1511
+ //#endregion
1512
+ //#region src/runtime/interaction-port.ts
1513
+ /** The official Host code for a late answer whose reply is already queued. */
1514
+ const QUESTION_REPLY_QUEUED = "REPLY_QUEUED";
1515
+ /** The official Host code for a late answer that does not name each question. */
1516
+ const QUESTION_BAD_ANSWER = "BAD_ANSWER";
1517
+ /**
1518
+ * One rejected late answer with the Host's stable code (`REPLY_QUEUED`,
1519
+ * `BAD_ANSWER`, `CALLER_NOT_LIVE`, or a transport/gateway code). Both
1520
+ * adapters throw THIS shape so a consumer can offer truthful recovery
1521
+ * without switching on a backend.
1522
+ */
1523
+ var QuestionAnswerError = class extends Error {
1524
+ code;
1525
+ constructor(code, message, options) {
1526
+ super(message, options);
1527
+ this.name = "QuestionAnswerError";
1528
+ this.code = code;
1529
+ }
1530
+ };
1531
+ /** Wrap an unknown failure from the late-answer path in the port vocabulary. */
1532
+ function questionAnswerError(error) {
1533
+ if (error instanceof QuestionAnswerError) return error;
1534
+ return new QuestionAnswerError(typeof error === "object" && error !== null && typeof error.code === "string" ? error.code : "question/answer-failed", typeof error === "object" && error !== null && typeof error.message === "string" ? error.message : String(error), { cause: error });
1535
+ }
1536
+ //#endregion
1537
+ //#region src/runtime/direct/file-completion/host-discovery.ts
1538
+ /**
1539
+ * The Direct Host discovery implementation (TS8-A, renamed by TS8-HF1): the
1540
+ * DIRECT process's filesystem behind the neutral {@link LocalDiscoveryDriver}
1541
+ * contract. It has two legitimate consumers:
1542
+ *
1543
+ * ```text
1544
+ * 1. the sessionless `HostFileScope { kind: 'workspace', cwd }` compatibility
1545
+ * path of `DirectHostFilePort` (the cold surface with no official carrier);
1546
+ * 2. the explicit-path `@` navigation route of the Session scope
1547
+ * (`runtime/direct/host-file-augmentation-direct.ts`), where the user
1548
+ * explicitly typed the scope (`@src/`, `@../`, `@/abs`, `@~/`).
1549
+ * ```
1550
+ *
1551
+ * A BARE Session `@` query (`@foo`) never runs through this adapter — that
1552
+ * path is the OFFICIAL Host authority's (`ctx.fileReferences.list` through the
1553
+ * port), whose candidates arrive already filtered, ranked and bounded.
1554
+ *
1555
+ * It owns the Direct Host process's own PATH/PATHEXT finder detection, its
1556
+ * `node:fs` facts and its fd/fdfind process. The small syscall-level
1557
+ * duplication against `client/file-completion/local-discovery.ts` is
1558
+ * deliberate: the two adapters read DIFFERENT machines under Remote, and one
1559
+ * concrete source owner for both localities is exactly what TS8-A removes.
1560
+ * @module @xmoon76/dsh-pi-tui/runtime/direct/file-completion/host-discovery
1561
+ */
1562
+ /**
1563
+ * The Direct Host process's explicit path-query environment facts. Under
1564
+ * Remote these are the HOST machine's facts, never the Client's; today Direct
1565
+ * runs in-process, so the numbers coincide — that coincidence is deliberately
1566
+ * not encoded in the domain contract.
1567
+ */
1568
+ function hostPathQueryEnvironment() {
1569
+ return {
1570
+ homeDir: homedir(),
1571
+ windowsHost: process.platform === "win32"
1572
+ };
1573
+ }
1574
+ /**
1575
+ * Locate an executable file-finder on the Direct Host process's PATH: `fd`
1576
+ * preferred, `fdfind` second. See the Client adapter for the probe contract.
1577
+ */
1578
+ function resolveFdPath() {
1579
+ const pathEntries = process.env.PATH?.split(delimiter) ?? [];
1580
+ const suffixes = (process.env.PATHEXT || (process.platform === "win32" ? ".COM;.EXE;.BAT;.CMD" : "")).split(";").map((suffix) => suffix.trim()).filter((suffix) => suffix !== "").flatMap((suffix) => suffix.toLowerCase() === suffix ? [suffix] : [suffix, suffix.toLowerCase()]);
1581
+ for (const name of ["fd", "fdfind"]) for (const entry of pathEntries) {
1582
+ const dir = entry === "" ? "." : entry;
1583
+ for (const suffix of ["", ...suffixes]) {
1584
+ const candidate = join(dir, `${name}${suffix}`);
1585
+ try {
1586
+ if (statSync(candidate).isFile() && accessSync(candidate, constants.X_OK) === void 0) return candidate;
1587
+ } catch {}
1588
+ }
1589
+ }
1590
+ return null;
1591
+ }
1592
+ /** Join a Host path WITHOUT collapsing a `..` segment. The Host's filesystem
1593
+ * backend anchors a POSIX path containing a `..` segment with its physical
1594
+ * spelling (the kernel resolves an intermediate symlink before the parent step),
1595
+ * so a search base that deliberately keeps that traversal may not be lexically
1596
+ * re-normalized on the way to a syscall. For every base the neutral resolver
1597
+ * already normalized (no `..` left) this is identical to `path.join`. */
1598
+ function hostJoin(baseDir, relative) {
1599
+ if (relative === "") return baseDir;
1600
+ return baseDir.endsWith("/") ? `${baseDir}${relative}` : `${baseDir}/${relative}`;
1601
+ }
1602
+ /** Whether one Dirent is a directory (symlinks followed; a broken link is a
1603
+ * file candidate — the fork's rule). */
1604
+ function entryIsDirectory(baseDir, name, entry) {
1605
+ if (entry.isDirectory()) return true;
1606
+ if (entry.isSymbolicLink()) try {
1607
+ return statSync(hostJoin(baseDir, name)).isDirectory();
1608
+ } catch {
1609
+ return false;
1610
+ }
1611
+ return false;
1612
+ }
1613
+ /** Run the Direct Host finder for one scoped query. Returns NULL on finder
1614
+ * FAILURE (the neutral policy then runs the bounded fallback), `[]` on a
1615
+ * genuine no-match or an abort. */
1616
+ function runHostFinder(fdPath, baseDir, term, signal) {
1617
+ const args = [
1618
+ "--base-directory",
1619
+ baseDir,
1620
+ "--max-results",
1621
+ String(100),
1622
+ "-i",
1623
+ "--full-path",
1624
+ "--print0",
1625
+ "--type",
1626
+ "f",
1627
+ "--type",
1628
+ "d",
1629
+ "--type",
1630
+ "l",
1631
+ "--hidden",
1632
+ "--exclude",
1633
+ ".git",
1634
+ "--exclude",
1635
+ ".git/*",
1636
+ "--exclude",
1637
+ ".git/**"
1638
+ ];
1639
+ if (term !== "") args.push(escapeFinderRegex(term));
1640
+ return new Promise((resolve) => {
1641
+ if (signal.aborted) {
1642
+ resolve([]);
1643
+ return;
1644
+ }
1645
+ let child;
1646
+ try {
1647
+ child = spawn(fdPath, args, { stdio: [
1648
+ "ignore",
1649
+ "pipe",
1650
+ "ignore"
1651
+ ] });
1652
+ } catch {
1653
+ resolve(null);
1654
+ return;
1655
+ }
1656
+ let stdout = "";
1657
+ let settled = false;
1658
+ const settle = (results) => {
1659
+ if (settled) return;
1660
+ settled = true;
1661
+ signal.removeEventListener("abort", onAbort);
1662
+ resolve(results);
1663
+ };
1664
+ const onAbort = () => {
1665
+ if (child.exitCode === null) child.kill("SIGKILL");
1666
+ settle([]);
1667
+ };
1668
+ signal.addEventListener("abort", onAbort, { once: true });
1669
+ child.stdout.setEncoding("utf-8");
1670
+ child.stdout.on("data", (chunk) => {
1671
+ stdout += chunk;
1672
+ });
1673
+ child.on("error", () => settle(null));
1674
+ child.on("close", (code) => {
1675
+ if (signal.aborted) {
1676
+ settle([]);
1677
+ return;
1678
+ }
1679
+ if (code !== 0) {
1680
+ settle(null);
1681
+ return;
1682
+ }
1683
+ const results = [];
1684
+ for (const record of normalizeFinderRecords(stdout)) {
1685
+ let isDirectory = record.directoryHint;
1686
+ if (!isDirectory) try {
1687
+ isDirectory = statSync(hostJoin(baseDir, record.path)).isDirectory();
1688
+ } catch {
1689
+ isDirectory = false;
1690
+ }
1691
+ results.push({
1692
+ path: record.path,
1693
+ kind: isDirectory ? "directory" : "file"
1694
+ });
1695
+ }
1696
+ settle(results);
1697
+ });
1698
+ });
1699
+ }
1700
+ /**
1701
+ * The Direct Host discovery driver: the Direct Host process's OWN filesystem,
1702
+ * with a Host PATH finder when present.
1703
+ *
1704
+ * `fdPath` is a test/API pin: `undefined` probes the Host PATH, `null` FORCES
1705
+ * the bounded fallback (no finder), a string pins one finder.
1706
+ */
1707
+ var DirectHostDiscoveryDriver = class {
1708
+ fdPathValue;
1709
+ constructor(fdPath = void 0) {
1710
+ this.fdPathValue = fdPath === void 0 ? resolveFdPath() : fdPath;
1711
+ }
1712
+ /** The pinned/detected Host finder (null = bounded-fallback only). */
1713
+ get fdPath() {
1714
+ return this.fdPathValue;
1715
+ }
1716
+ async listDirectory(baseDir, relativeDir, signal) {
1717
+ if (signal.aborted) return null;
1718
+ let entries;
1719
+ try {
1720
+ entries = readdirSync(hostJoin(baseDir, relativeDir), { withFileTypes: true });
1721
+ } catch {
1722
+ return null;
1723
+ }
1724
+ if (signal.aborted) return null;
1725
+ const dir = hostJoin(baseDir, relativeDir);
1726
+ return entries.map((entry) => ({
1727
+ name: entry.name,
1728
+ kind: entryIsDirectory(dir, entry.name, entry) ? "directory" : "file",
1729
+ descendable: entry.isDirectory() && !entry.isSymbolicLink()
1730
+ }));
1731
+ }
1732
+ async find(baseDir, term, signal) {
1733
+ if (this.fdPathValue === null) return null;
1734
+ return runHostFinder(this.fdPathValue, baseDir, term, signal);
1735
+ }
1736
+ };
1737
+ //#endregion
1738
+ //#region src/runtime/direct/host-file-augmentation-direct.ts
1739
+ /**
1740
+ * The session `@`-reference Host router (TS8-HF1): ONE Host-side authority
1741
+ * that answers a completion query with the routing boundary
1742
+ *
1743
+ * ```text
1744
+ * bare workspace fuzzy search -> official ctx.fileReferences.list
1745
+ * @foo @readme @src @.env (workspace index, cache, exclusions,
1746
+ * scoring, maxResults stay authoritative)
1747
+ *
1748
+ * explicit path navigation -> dsh-pi-tui Host scoped discovery
1749
+ * @src/ @src/uti @./ @../shared/foo (the exact scope the user typed:
1750
+ * @../../foo @/tmp/foo @~/Down recursive fuzzy inside it, explicit
1751
+ * Windows drive @C:/work/foo and UNC parent/absolute/native paths)
1752
+ * ```
1753
+ *
1754
+ * The decision is the TOKEN's explicit path navigation, never the resolved
1755
+ * search base's workspace containment (the superseded v1 rule): a user-typed
1756
+ * separator, a root form (`.`/`..`/`~`) or a Windows absolute path is what
1757
+ * selects the scoped route, so `@dist/` and `@link/foo` address their
1758
+ * explicitly named scope even when the official provider would exclude or not
1759
+ * traverse it.
1760
+ *
1761
+ * POSIX BOUNDARY: on a POSIX Host a backslash is an ordinary FILENAME character,
1762
+ * but the reused TS8-A path resolver selects its Windows dialect from the token
1763
+ * alone and normalizes the separators (a pinned cross-dialect contract of the
1764
+ * Client-local completion — `test/domain-file-completion.test.ts`). The
1765
+ * augmentation therefore does NOT CLAIM an ambiguous POSIX token that contains a
1766
+ * backslash: such a token is left to the official provider rather than searched
1767
+ * in a scope we cannot represent verbatim. This is a deliberate fail-closed
1768
+ * narrowing, not a claim that the token is a bare fuzzy query; delete the guard
1769
+ * once the shared resolver distinguishes host dialect, token dialect and literal
1770
+ * backslashes.
1771
+ *
1772
+ * PARENT-TRAVERSAL SPELLING: a relative scope is searched with the SAME spelling
1773
+ * the Host's local filesystem backend gives the accepted value. On a POSIX Host a
1774
+ * path containing a `..` segment is anchored with its PHYSICAL spelling by
1775
+ * `dsh-fs-local` (kernel resolves an intermediate symlink before the parent step);
1776
+ * the scoped search therefore keeps the raw `<cwd>/<displayBase>` concatenation for
1777
+ * those queries instead of the lexically joined directory, so completion and the
1778
+ * model's read can never land in two different physical directories. Windows, the
1779
+ * absolute forms (already verbatim) and the home shorthand (absolute normalized
1780
+ * value) keep the shared resolver's spelling.
1781
+ *
1782
+ * The official provider is called ONLY on the bare route; an authoritative
1783
+ * official `[]` stays empty and never falls back to the scanner. The scoped
1784
+ * route never calls the official provider and needs no capability from it.
1785
+ *
1786
+ * Home shorthand (`@~`, `@~/Down`) is dsh-pi-tui INPUT syntax: discovery runs
1787
+ * against the Host HOME, but every returned candidate is materialized as an
1788
+ * ABSOLUTE Host path — DSH path resolution performs no shell-style `~`
1789
+ * expansion, so a literal `~/...` completion value would be unusable. Ranking
1790
+ * always scores the SCOPE-RELATIVE candidate, never the materialized absolute
1791
+ * path (the home prefix itself must not satisfy the path-substring tier).
1792
+ *
1793
+ * This authority receives HOST facts only (the live Agent, the Host discovery
1794
+ * driver, the Host path environment); it never reads the Client cwd, home or
1795
+ * platform.
1796
+ *
1797
+ * TODO(upstream-file-reference-explicit-scope): Delete the pi-tui
1798
+ * explicit-scope augmentation when the minimum supported DSH fileReferences
1799
+ * provider supports equivalent explicit scoped recursive search,
1800
+ * parent/absolute Host path navigation, explicit excluded/symlink scope
1801
+ * access, and a Host-home-safe completion representation.
1802
+ *
1803
+ * @module @xmoon76/dsh-pi-tui/runtime/direct/host-file-augmentation-direct
1804
+ */
1805
+ /** Whether the token names an EXPLICIT path scope (the frozen classifier,
1806
+ * decided from the raw token and the HOST platform — never from filesystem
1807
+ * existence, official result count, or workspace containment).
1808
+ *
1809
+ * A POSIX host has ONE separator: any `/` is an explicit scope, and the root
1810
+ * forms are explicit. A token that also contains a backslash is deliberately NOT
1811
+ * claimed (see the module comment): the reused resolver cannot represent its
1812
+ * literal POSIX scope, so the official provider keeps it.
1813
+ *
1814
+ * A Windows host treats `\` as a separator as well and accepts any
1815
+ * win32-absolute form (drive/UNC) even before its first separator is visible. */
1816
+ function isExplicitPathNavigation(raw, environment) {
1817
+ if (raw === "") return false;
1818
+ if (environment.windowsHost) return raw === "." || raw === ".." || raw === "~" || raw.includes("/") || raw.includes("\\") || win32.isAbsolute(raw);
1819
+ if (raw.includes("\\")) return false;
1820
+ return raw === "." || raw === ".." || raw === "~" || raw.includes("/");
1821
+ }
1822
+ /** Whether the token is a leading-home form (`~`, `~/x`, `~\x`). */
1823
+ function isHomeToken(raw) {
1824
+ return raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\");
1825
+ }
1826
+ /** Materialize one candidate discovered under a HOME scope as an ABSOLUTE Host
1827
+ * path (the `~` shorthand must never reach the prompt as literal text). */
1828
+ function materializeHostPath(candidate, searchBase, windowsHost) {
1829
+ return {
1830
+ path: (windowsHost ? win32 : posix).join(searchBase, candidate.path),
1831
+ kind: candidate.kind
1832
+ };
1833
+ }
1834
+ /** One `..` path SEGMENT — the exact shape the Host filesystem backend keys its
1835
+ * physical-spelling rule on (`dsh-fs-local`'s `localDisplayPath`). */
1836
+ const PARENT_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/u;
1837
+ /**
1838
+ * The directory one explicit scope searches.
1839
+ *
1840
+ * The Host's local filesystem backend anchors a POSIX path that contains a `..`
1841
+ * SEGMENT with its PHYSICAL spelling (the raw `<anchor>/<path>`, deliberately
1842
+ * unnormalized), so the kernel resolves an intermediate symlink BEFORE the parent
1843
+ * step; every other spelling goes through a lexical `resolve(anchor, path)`, and
1844
+ * Windows always resolves lexically. Two consequences drive this function:
1845
+ *
1846
+ * 1. The anchor is `<hostCwd>` for a relative scope and the value itself for an
1847
+ * absolute one, and the rule tests the WHOLE anchored spelling — so a `..`
1848
+ * carried by the SESSION CWD flips it just like one in the typed scope. The
1849
+ * consumer applies that rule to the value we hand it (`displayBase + name`), so
1850
+ * the search must use the same spelling; otherwise the completion could offer a
1851
+ * path the model's read resolves into a DIFFERENT physical directory.
1852
+ * 2. A home shorthand emits an ABSOLUTE value through `path.join`, which normalizes
1853
+ * lexically. Its scope must be normalized the same way: the bare `~`/`~/` root
1854
+ * form is the one form whose search base is the raw `homedir()` string, so a
1855
+ * `..`-bearing HOME would otherwise be searched physically while the value we
1856
+ * emit names the lexical directory.
1857
+ *
1858
+ * Absolute forms already spell their traversal verbatim. This is one segment test
1859
+ * plus one concatenation (plus one lexical normalize for the home root form) — the
1860
+ * consumer's own rule applied to the value we are about to hand it, not a second
1861
+ * path parser.
1862
+ */
1863
+ function scopeSearchBase(resolved, raw, hostCwd, environment) {
1864
+ const api = environment.windowsHost ? win32 : posix;
1865
+ if (isHomeToken(raw)) return api.normalize(resolved.searchBase);
1866
+ if (environment.windowsHost) return resolved.searchBase;
1867
+ if (raw.startsWith("/") || resolved.winAbsolute) return resolved.searchBase;
1868
+ const physicalScope = `${hostCwd}/${resolved.displayBase}`;
1869
+ return PARENT_SEGMENT.test(physicalScope) ? physicalScope : resolved.searchBase;
1870
+ }
1871
+ /**
1872
+ * Answer one session `@` completion query over the Host facts.
1873
+ *
1874
+ * Cancellation (the caller's `signal`) outranks every route: an aborted
1875
+ * request rejects at entry — before the route/capability decision — and again
1876
+ * after each await, so a cancelled scope never serves a late result and never
1877
+ * reports `unavailable`.
1878
+ *
1879
+ * @param agent - the live Agent the session scope resolved to.
1880
+ * @param query - the official query form (path text following `@`).
1881
+ * @param signal - caller cancellation.
1882
+ * @param deps - the Host facts this router answers with.
1883
+ */
1884
+ async function listPiTuiHostFileReferences(agent, query, signal, deps) {
1885
+ signal.throwIfAborted();
1886
+ if (!isExplicitPathNavigation(query, deps.environment)) {
1887
+ const official = deps.official;
1888
+ if (official === void 0) return {
1889
+ kind: "unavailable",
1890
+ reason: "the official Host file-reference service is not mounted"
1891
+ };
1892
+ const candidates = await official.list(agent, query, signal);
1893
+ signal.throwIfAborted();
1894
+ return {
1895
+ kind: "ok",
1896
+ items: candidates.map((candidate) => ({
1897
+ path: candidate.path,
1898
+ kind: candidate.kind
1899
+ }))
1900
+ };
1901
+ }
1902
+ const workspaceCwd = agent.session.header.cwd ?? process.cwd();
1903
+ const resolved = resolvePathQuery(query, workspaceCwd, deps.environment);
1904
+ const searchBase = scopeSearchBase(resolved, query, workspaceCwd, deps.environment);
1905
+ const discovered = await discoverForQuery(searchBase === resolved.searchBase ? resolved : {
1906
+ ...resolved,
1907
+ searchBase
1908
+ }, deps.driver, signal);
1909
+ signal.throwIfAborted();
1910
+ const homeToken = isHomeToken(query);
1911
+ return {
1912
+ kind: "ok",
1913
+ items: rankPathCandidates(homeToken ? discovered : discovered.map((candidate) => reattachDisplayBase(candidate, resolved)), resolved.searchTerm).map((candidate) => homeToken ? materializeHostPath(candidate, resolved.searchBase, deps.environment.windowsHost) : {
1914
+ path: candidate.path,
1915
+ kind: candidate.kind
1916
+ })
1917
+ };
1918
+ }
1919
+ //#endregion
1920
+ //#region src/runtime/plugin-manager-mapping.ts
1921
+ /** Generate one install request id (the branded shape is a plain string). */
1922
+ function pluginInstallRequestId(value) {
1923
+ return value;
1924
+ }
1925
+ /** Resolve one official localized text to the English fallback. */
1926
+ function localizedText(value) {
1927
+ if (value === void 0) return void 0;
1928
+ return typeof value === "string" ? value : value.en;
1929
+ }
1930
+ function detachIncompatible(entry) {
1931
+ return Object.freeze({
1932
+ name: entry.name,
1933
+ version: entry.version,
1934
+ runtimeVersion: entry.runtimeVersion,
1935
+ peers: Object.freeze({ ...entry.peers })
1936
+ });
1937
+ }
1938
+ function detachError(error) {
1939
+ if (error === void 0) return void 0;
1940
+ return Object.freeze({
1941
+ code: error.code,
1942
+ ...error.diagnostic === void 0 ? {} : { diagnostic: error.diagnostic },
1943
+ ...error.incompatible === void 0 ? {} : { incompatible: Object.freeze(error.incompatible.map(detachIncompatible)) }
1944
+ });
1945
+ }
1946
+ function pickMeta(meta) {
1947
+ const title = localizedText(meta.title);
1948
+ const description = localizedText(meta.description);
1949
+ return {
1950
+ ...title === void 0 ? {} : { title },
1951
+ ...description === void 0 ? {} : { description }
1952
+ };
1953
+ }
1954
+ function detachBundle(bundle) {
1955
+ const rows = bundle.rows.map((row) => Object.freeze({
1956
+ rowId: row.rowId,
1957
+ moduleName: row.moduleName,
1958
+ ...row.meta === void 0 ? {} : pickMeta(row.meta),
1959
+ ...row.entryId === void 0 ? {} : { entryId: String(row.entryId) }
1960
+ }));
1961
+ return Object.freeze({
1962
+ name: bundle.name,
1963
+ ...bundle.version === void 0 ? {} : { version: bundle.version },
1964
+ ...bundle.meta === void 0 ? {} : pickMeta(bundle.meta),
1965
+ ...bundle.description === void 0 ? {} : { description: bundle.description },
1966
+ enabled: bundle.enabled,
1967
+ installed: bundle.installed,
1968
+ optional: bundle.optional,
1969
+ removable: bundle.removable,
1970
+ ...bundle.readOnlyReason === void 0 ? {} : { readOnlyReason: bundle.readOnlyReason },
1971
+ ...bundle.error === void 0 ? {} : { error: detachError(bundle.error) },
1972
+ rows: Object.freeze(rows),
1973
+ overrides: Object.freeze([...bundle.overrides])
1974
+ });
1975
+ }
1976
+ function detachPlugin(entry) {
1977
+ const addressable = "patchId" in entry;
1978
+ return Object.freeze({
1979
+ entryId: String(entry.entryId),
1980
+ moduleName: entry.moduleName,
1981
+ ...entry.meta === void 0 ? {} : pickMeta(entry.meta),
1982
+ enabled: entry.enabled,
1983
+ fiberPhase: entry.fiberPhase,
1984
+ ...addressable ? { patchId: entry.patchId } : { readOnlyReason: entry.readOnlyReason }
1985
+ });
1986
+ }
1987
+ function detachRegistries(registries) {
1988
+ return Object.freeze({
1989
+ registry: registries.registry,
1990
+ fallbackRegistries: Object.freeze([...registries.fallbackRegistries]),
1991
+ resolved: registries.resolved
1992
+ });
1993
+ }
1994
+ function detachExemptions(value) {
1995
+ return Object.freeze(Object.entries(value.exemptions).map(([packageVersion, runtimeVersions]) => Object.freeze({
1996
+ packageVersion,
1997
+ runtimeVersions: Object.freeze([...runtimeVersions])
1998
+ })));
1999
+ }
2000
+ /** Map one complete snapshot read onto the detached surface facts. */
2001
+ function detachPluginManagerSnapshot(input) {
2002
+ return Object.freeze({
2003
+ bundles: Object.freeze(input.bundles.map(detachBundle)),
2004
+ plugins: Object.freeze(input.plugins.map(detachPlugin)),
2005
+ registries: detachRegistries(input.registries),
2006
+ exemptions: detachExemptions(input.exemptions),
2007
+ exemptionWarnings: Object.freeze([...input.exemptions.warnings])
2008
+ });
2009
+ }
2010
+ /** Map one persisted change and its observed application outcome. */
2011
+ function detachChange(result) {
2012
+ return Object.freeze({
2013
+ changed: result.changed,
2014
+ application: result.application,
2015
+ stage: result.stage,
2016
+ target: result.target,
2017
+ ...result.error === void 0 ? {} : { error: detachError(result.error) },
2018
+ ...result.warnings === void 0 ? {} : { warnings: Object.freeze([...result.warnings]) },
2019
+ ...result.bundle === void 0 ? {} : { bundle: result.bundle }
2020
+ });
2021
+ }
2022
+ /** Map what a spec names before installation. */
2023
+ function detachInspection(inspection) {
2024
+ if (inspection.status === "accepted") return Object.freeze({
2025
+ status: "accepted",
2026
+ kind: inspection.kind,
2027
+ ...inspection.name === void 0 ? {} : { name: inspection.name },
2028
+ ...inspection.version === void 0 ? {} : { version: inspection.version },
2029
+ ...inspection.description === void 0 ? {} : { description: inspection.description },
2030
+ bundle: inspection.bundle,
2031
+ registry: inspection.registry,
2032
+ ...inspection.host === void 0 ? {} : { host: inspection.host }
2033
+ });
2034
+ return Object.freeze({
2035
+ status: "refused",
2036
+ problem: inspection.problem,
2037
+ reason: inspection.reason,
2038
+ ...inspection.registries === void 0 ? {} : { registries: Object.freeze([...inspection.registries]) }
2039
+ });
2040
+ }
2041
+ /** Map one official install-phase announcement. */
2042
+ function detachPhase(progress) {
2043
+ return Object.freeze({
2044
+ requestId: String(progress.requestId),
2045
+ phase: progress.phase,
2046
+ ...progress.attempt === void 0 ? {} : { attempt: Object.freeze({
2047
+ registry: progress.attempt.registry,
2048
+ index: progress.attempt.index,
2049
+ total: progress.attempt.total
2050
+ }) }
2051
+ });
2052
+ }
2053
+ /** Map one pnpm output chunk. */
2054
+ function detachLog(chunk) {
2055
+ return Object.freeze({
2056
+ ...chunk.requestId === void 0 ? {} : { requestId: String(chunk.requestId) },
2057
+ jobId: chunk.jobId,
2058
+ stream: chunk.stream,
2059
+ text: chunk.text
2060
+ });
2061
+ }
2062
+ //#endregion
2063
+ //#region src/domain/terminal-progress/interval.ts
2064
+ /**
2065
+ * Classify one official `turn/end.reason.kind` (plan §5.2). `completed` is the
2066
+ * DSH turn result (not a claim about every business step); `error` and
2067
+ * `max-tokens` (a resource ceiling, not success) are `error`; a cancelled /
2068
+ * blocked / non-live closer is honestly `idle`. An UNKNOWN upstream kind is
2069
+ * NOT guessed — the caller records it and the interval stays `idle`.
2070
+ *
2071
+ * @param kind - the durable `turn/end` reason kind.
2072
+ * @returns the proven outcome, or `undefined` for an unknown kind.
2073
+ */
2074
+ function classifyTerminalTurnEnd(kind) {
2075
+ switch (kind) {
2076
+ case "completed": return "done";
2077
+ case "error": return "error";
2078
+ case "max-tokens": return "error";
2079
+ case "aborted":
2080
+ case "blocked":
2081
+ case "interrupted":
2082
+ case "forked": return "idle";
2083
+ default: return;
2084
+ }
2085
+ }
2086
+ /**
2087
+ * Create one independent fold instance.
2088
+ * @param onUnknownReason - reports an unknown `turn/end.reason.kind` (the
2089
+ * Host/Direct owner injects its diagnostics channel here); the interval stays
2090
+ * `idle` instead of guessing.
2091
+ */
2092
+ function createTerminalProgressInterval(onUnknownReason) {
2093
+ let active = false;
2094
+ /** The turn opened by the newest live `turn/start` of THIS interval. */
2095
+ let openTurn;
2096
+ /** The classified outcome of the newest turn whose `turn/end` matched the
2097
+ * open turn — reset whenever a newer turn opens. */
2098
+ let lastClosed;
2099
+ /** The currently committed outcome of the interval (`idle` while running
2100
+ * without a proven result). */
2101
+ let outcome = "idle";
2102
+ const state = () => ({
2103
+ active,
2104
+ outcome
2105
+ });
2106
+ return {
2107
+ status(running) {
2108
+ if (running) {
2109
+ if (active) return void 0;
2110
+ active = true;
2111
+ openTurn = void 0;
2112
+ lastClosed = void 0;
2113
+ outcome = "idle";
2114
+ return state();
2115
+ }
2116
+ if (!active) return void 0;
2117
+ outcome = openTurn === void 0 ? lastClosed ?? "idle" : "idle";
2118
+ active = false;
2119
+ openTurn = void 0;
2120
+ lastClosed = void 0;
2121
+ return state();
2122
+ },
2123
+ turnStart(turn) {
2124
+ if (!active) return;
2125
+ openTurn = turn;
2126
+ lastClosed = void 0;
2127
+ },
2128
+ turnEnd(turn, reasonKind) {
2129
+ if (!active) return;
2130
+ if (openTurn !== turn) return;
2131
+ openTurn = void 0;
2132
+ const classified = classifyTerminalTurnEnd(reasonKind);
2133
+ if (classified === void 0) {
2134
+ onUnknownReason?.(reasonKind);
2135
+ lastClosed = "idle";
2136
+ return;
2137
+ }
2138
+ lastClosed = classified;
2139
+ },
2140
+ retire() {
2141
+ openTurn = void 0;
2142
+ lastClosed = void 0;
2143
+ if (!active && outcome === "idle") return void 0;
2144
+ active = false;
2145
+ return state();
2146
+ },
2147
+ apply(fact) {
2148
+ active = fact.active;
2149
+ outcome = fact.outcome;
2150
+ openTurn = void 0;
2151
+ lastClosed = void 0;
2152
+ return state();
2153
+ },
2154
+ reset() {
2155
+ active = false;
2156
+ openTurn = void 0;
2157
+ lastClosed = void 0;
2158
+ outcome = "idle";
2159
+ return state();
2160
+ },
2161
+ snapshot() {
2162
+ return state();
2163
+ }
2164
+ };
2165
+ }
2166
+ //#endregion
2167
+ //#region src/runtime/remote/pi-tui-terminal-progress-contract.ts
2168
+ /** The wire namespace (and Host Cordis service key) of the private stream. */
2169
+ const PI_TUI_TERMINAL_PROGRESS_NAMESPACE = "piTuiTerminalProgress";
2170
+ /** The private method's stable endpoint identity. */
2171
+ const PI_TUI_TERMINAL_PROGRESS_WATCH_ENDPOINT = "@xmoon76/dsh-pi-tui#piTuiTerminalProgress/watch";
2172
+ /** One strict JSON codec that validates a wire string. */
2173
+ function stringCodec(typeSymbol) {
2174
+ return {
2175
+ mode: "strict",
2176
+ typeSymbol,
2177
+ create: () => ({ parse: (value) => {
2178
+ if (typeof value !== "string") throw new TypeError(`${typeSymbol}: the wire value must be a string`);
2179
+ return value;
2180
+ } })
2181
+ };
2182
+ }
2183
+ /**
2184
+ * Validate and detach one `PiTuiTerminalProgressFrame`. This is the ONE
2185
+ * structural validator of the frame shape: the descriptor's `result` codec
2186
+ * uses it, and the R2 Client source must call it on every downlink item because
2187
+ * the framework does not (see the module JSDoc).
2188
+ */
2189
+ function parsePiTuiTerminalProgressFrame(value) {
2190
+ if (typeof value !== "object" || value === null) throw new TypeError("piTuiTerminalProgress/watch: the frame must be an object");
2191
+ const frame = value;
2192
+ if (frame.kind !== "snapshot" && frame.kind !== "update") throw new TypeError("piTuiTerminalProgress/watch: the frame kind must be snapshot or update");
2193
+ if (typeof frame.sessionId !== "string") throw new TypeError("piTuiTerminalProgress/watch: the frame needs a string sessionId");
2194
+ if (typeof frame.hostEpoch !== "string") throw new TypeError("piTuiTerminalProgress/watch: the frame needs a string hostEpoch");
2195
+ if (typeof frame.agentEpoch !== "number") throw new TypeError("piTuiTerminalProgress/watch: the frame needs a numeric agentEpoch");
2196
+ if (typeof frame.revision !== "number") throw new TypeError("piTuiTerminalProgress/watch: the frame needs a numeric revision");
2197
+ if (typeof frame.running !== "boolean") throw new TypeError("piTuiTerminalProgress/watch: the frame needs a boolean running");
2198
+ if (frame.outcome !== "idle" && frame.outcome !== "done" && frame.outcome !== "error") throw new TypeError("piTuiTerminalProgress/watch: the frame outcome must be idle, done or error");
2199
+ return {
2200
+ kind: frame.kind,
2201
+ sessionId: frame.sessionId,
2202
+ hostEpoch: frame.hostEpoch,
2203
+ agentEpoch: frame.agentEpoch,
2204
+ revision: frame.revision,
2205
+ running: frame.running,
2206
+ outcome: frame.outcome
2207
+ };
2208
+ }
2209
+ const PI_TUI_TERMINAL_PROGRESS_FRAME_CODEC = {
2210
+ mode: "strict",
2211
+ typeSymbol: "@xmoon76/dsh-pi-tui#piTuiTerminalProgress/watch:frame",
2212
+ create: () => ({ parse: parsePiTuiTerminalProgressFrame }),
2213
+ decode: parsePiTuiTerminalProgressFrame
2214
+ };
2215
+ /**
2216
+ * The ONE shared invocation descriptor both contributions derive from. The
2217
+ * Host registers this exact object; the Client mounts this exact object.
2218
+ */
2219
+ const PI_TUI_TERMINAL_PROGRESS_WATCH = {
2220
+ id: PI_TUI_TERMINAL_PROGRESS_WATCH_ENDPOINT,
2221
+ service: PI_TUI_TERMINAL_PROGRESS_NAMESPACE,
2222
+ namespace: PI_TUI_TERMINAL_PROGRESS_NAMESPACE,
2223
+ method: "watch",
2224
+ mode: "stream",
2225
+ invocation: { kind: "direct" },
2226
+ parameters: [{
2227
+ name: "sessionId",
2228
+ wire: "sessionId",
2229
+ source: "json",
2230
+ codec: stringCodec("@xmoon76/dsh-pi-tui#piTuiTerminalProgress/watch:sessionId")
2231
+ }],
2232
+ cancellation: { parameter: "signal" },
2233
+ result: PI_TUI_TERMINAL_PROGRESS_FRAME_CODEC
2234
+ };
2235
+ //#endregion
2236
+ //#region src/app/remote/terminal-progress-source.ts
2237
+ /** Consecutive frame-less watch endings tolerated before failing closed. */
2238
+ const EMPTY_WATCH_REOPEN_LIMIT = 3;
2239
+ /**
2240
+ * Consume the ONE Remote progress watch of one session for as long as the caller
2241
+ * stays current, re-establishing it whenever the Host retires it.
2242
+ *
2243
+ * @param source - the Remote progress source.
2244
+ * @param sessionId - the main session this authority belongs to.
2245
+ * @param signal - the caller's lifetime; aborting it stops the consumption at
2246
+ * the next fact and before any re-open.
2247
+ * @param consumer - the currentness fence and the fact sink.
2248
+ * @throws when the Host keeps ending the watch without ever delivering a frame.
2249
+ */
2250
+ async function consumeRemoteTerminalProgress(source, sessionId, signal, consumer) {
2251
+ let emptyReopens = 0;
2252
+ for (;;) {
2253
+ let frames = 0;
2254
+ try {
2255
+ for await (const fact of source.open(sessionId, signal)) {
2256
+ if (signal.aborted || !consumer.isCurrent()) return;
2257
+ frames += 1;
2258
+ consumer.onFact(fact);
2259
+ }
2260
+ } catch (error) {
2261
+ if (signal.aborted) return;
2262
+ throw error;
2263
+ }
2264
+ if (signal.aborted || !consumer.isCurrent()) return;
2265
+ if (frames === 0) {
2266
+ emptyReopens += 1;
2267
+ if (emptyReopens > EMPTY_WATCH_REOPEN_LIMIT) throw new Error(`the Remote terminal-progress watch of ${sessionId} ended without a frame ${String(emptyReopens)} times`);
2268
+ } else emptyReopens = 0;
2269
+ }
2270
+ }
2271
+ /**
2272
+ * Assemble the source over the ONE Remote Client graph. It constructs no
2273
+ * Client and owns no subscription of its own; the provenance of an accepted
2274
+ * frame lives only for the watch that observed it (see {@link createRemoteTerminalProgressSource}).
2275
+ */
2276
+ function createRemoteTerminalProgressSource(deps) {
2277
+ /**
2278
+ * Judge one frame against the provenance of THIS watch.
2279
+ *
2280
+ * @param previous - the last frame accepted by this watch, or `undefined`
2281
+ * before its opening baseline. It deliberately never crosses watches: a
2282
+ * settled `done`/`error` belongs to the interval ONE watch observed, so a new
2283
+ * watch (a reconnect, a re-adopted session, a re-retained binding, another TUI
2284
+ * owner) opens on the Host's current `running` truth and `idle` — the plan's
2285
+ * "a new binding default to idle" — instead of inheriting a result it cannot
2286
+ * prove.
2287
+ * @param opened - whether this watch already delivered its opening baseline.
2288
+ * @returns the fact to publish, or `undefined` for a frame that must be dropped.
2289
+ * @throws when the stream cannot be interpreted at all.
2290
+ */
2291
+ const acceptFrame = (frame, sessionId, opened, previous) => {
2292
+ if (!opened) {
2293
+ if (frame.kind !== "snapshot") throw new Error(`the Remote terminal-progress stream of ${sessionId} did not open with a snapshot`);
2294
+ return {
2295
+ kind: "snapshot",
2296
+ restart: true,
2297
+ running: frame.running,
2298
+ outcome: "idle"
2299
+ };
2300
+ }
2301
+ if (previous !== void 0 && frame.revision < previous.revision) throw new Error(`the Remote terminal-progress stream of ${sessionId} moved its revision backwards`);
2302
+ if (frame.kind === "snapshot") return {
2303
+ kind: "snapshot",
2304
+ restart: true,
2305
+ running: frame.running,
2306
+ outcome: "idle"
2307
+ };
2308
+ if (previous === void 0) return void 0;
2309
+ if (frame.hostEpoch !== previous.hostEpoch) return;
2310
+ if (frame.agentEpoch < previous.agentEpoch) return void 0;
2311
+ if (frame.revision <= previous.revision) return void 0;
2312
+ return {
2313
+ kind: "update",
2314
+ restart: frame.agentEpoch > previous.agentEpoch,
2315
+ running: frame.running,
2316
+ outcome: frame.outcome
2317
+ };
2318
+ };
2319
+ return { async *open(sessionId, signal) {
2320
+ const capturedGeneration = deps.generation.getSnapshot();
2321
+ const capturedBinding = deps.binding(sessionId);
2322
+ if (capturedGeneration === void 0 || capturedBinding === void 0) throw new Error(`the Remote terminal-progress watch of ${sessionId} has no retained Session binding`);
2323
+ const handle = deps.remote.piTuiTerminalProgress.watch(sessionId, signal);
2324
+ try {
2325
+ let opened = false;
2326
+ /** This watch's own provenance: it dies with the watch. */
2327
+ let accepted;
2328
+ for await (const item of handle) {
2329
+ if (signal.aborted) return;
2330
+ if (!Object.is(capturedGeneration, deps.generation.getSnapshot()) || deps.binding(sessionId) !== capturedBinding) throw new Error(`the Remote terminal-progress watch of ${sessionId} lost its transport identity`);
2331
+ const frame = parsePiTuiTerminalProgressFrame(item);
2332
+ if (frame.sessionId !== sessionId) throw new Error(`the Remote terminal-progress stream of ${sessionId} delivered a frame of ${frame.sessionId}`);
2333
+ const fact = acceptFrame(frame, sessionId, opened, accepted);
2334
+ if (fact === void 0) continue;
2335
+ opened = true;
2336
+ accepted = {
2337
+ hostEpoch: frame.hostEpoch,
2338
+ agentEpoch: frame.agentEpoch,
2339
+ revision: frame.revision
2340
+ };
2341
+ yield fact;
2342
+ }
2343
+ } finally {
2344
+ handle.dispose();
2345
+ }
2346
+ } };
2347
+ }
2348
+ //#endregion
2349
+ export { effectiveCustomCommandRefreshMs as $, directAgentOf as A, SupersededReadError as B, safeSubagentErrorMessage as C, REMOTE_IMPLEMENTED_CAPABILITIES as D, DIRECT_IMPLEMENTED_CAPABILITIES as E, ROUTE_PATTERN as F, discoverForQuery as G, reattachDisplayBase as H, credentialOptionsFor as I, DEFAULT_CUSTOM_COMMAND_REFRESH_MS as J, escapeFinderRegex as K, deriveKeyRef as L, requireCreated as M, requireOpened as N, LifecycleError as O, PROTOCOL_CHOICES as P, customItemName as Q, providerOptionsFor as R, classifySubagentPromptSettlement as S, isRemoteBusinessRefusalCode as T, resolvePathQuery as U, rankPathCandidates as V, separatorOfRaw as W, customCommandConfigOf as X, activeFooterItemIds as Y, customItemId as Z, QUESTION_REPLY_QUEUED as _, safeErrorString as _t, createTerminalProgressInterval as a, resolveTrustedFooterCommand as at, detachedSessionStatus as b, detachLog as c, resolveUserLayerFooterMode as ct, pluginInstallRequestId as d, cancellationError as dt, effectiveCustomCommandTimeoutMs as et, listPiTuiHostFileReferences as f, isCancellation as ft, QUESTION_BAD_ANSWER as g, safeErrorMessage as gt, resolveFdPath as h, runOwned as ht, PI_TUI_TERMINAL_PROGRESS_WATCH as i, parseFooterCommandConfig as it, ownerHandleOf as j, clientOwnerOf as k, detachPhase as l, MAX_COMMAND_TIMEOUT_MS as lt, hostPathQueryEnvironment as m, runDetached as mt, createRemoteTerminalProgressSource as n, parseFooterCustomItem as nt, detachChange as o, resolveUserCommandItemActivationIds as ot, DirectHostDiscoveryDriver as p, observeSettled as pt, normalizeFinderRecords as q, PI_TUI_TERMINAL_PROGRESS_NAMESPACE as r, parseFooterCustomItems as rt, detachInspection as s, resolveUserCommandItemFallbackActivationIds as st, consumeRemoteTerminalProgress as t, executableCommandItemIds as tt, detachPluginManagerSnapshot as u, contextPressureOccupancy as ut, QuestionAnswerError as v, GATEWAY_PRE_INVOCATION_CODES as w, detachedTurnOutline as x, questionAnswerError as y, resolveCredentialArg as z };