@iseoane/agent-depot 0.6.14 → 0.6.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.16] - 2026-11-13
11
+
12
+ ### Changed
13
+
14
+ - `app install` names the Hosts whose setup the recipe declares and the `app setup` command to run, since install never runs Host setup and wiring is not tracked (#131).
15
+ - The built-in `agent-depot-apprecipe` Skill (1.3) checks Host wiring in its preflight (unattended setup per Host, conflicting MCP entries, plugins and packages), prefers `argv` setup when possible, rechecks manual setup on repair and hands off the pending `app setup` command per Host (#131).
16
+
17
+ ## [0.6.15] - 2026-11-12
18
+
19
+ ### Changed
20
+
21
+ - Sources marks a refreshed Source as `✓ refreshed` without the elapsed time;
22
+ `… refreshing Ns` keeps its counter (#123).
23
+ - Progress lines share one format; a one-item batch no longer shows `(1/1)` (#123).
24
+ - Update previews say "No updates can be applied" when nothing applies, empty
25
+ previews no longer show "Review 1 of 0", and commit-only Package versions read
26
+ `commit xxxxxxx` everywhere (#124).
27
+ - Wrapped preview rows keep their indent and step guide; long titles are shortened
28
+ so the confirmation question always stays on screen (#125).
29
+
30
+ ### Fixed
31
+
32
+ - Installations marks a tracked App whose recipe changed as needing approval and
33
+ Enter opens the recipe review; an invalid recipe shows its reason (#127).
34
+ - A Source cache lock left by a process that died is recovered safely, and a busy
35
+ lock names its holder (#120, #122).
36
+ - Escapes typed in text can no longer be cut so they look like real ones, uppercase
37
+ `\X`/`\U` are handled, and quoted paths mark invisible characters (#128).
38
+ - State files are flushed to disk before and after replacing them, so a power cut
39
+ does not leave them empty (#129).
40
+
41
+ ### Changed (development)
42
+
43
+ - The TUI start refresh is an injected option; confirmation and file-picker tests no
44
+ longer depend on terminal size, and review coverage gaps are closed (#119, #121, #126).
45
+
10
46
  ## [0.6.14] - 2026-11-12
11
47
 
12
48
  ### Added
package/README.md CHANGED
@@ -972,7 +972,8 @@ independently. Unknown selections and combining a block's inclusion filter with
972
972
 
973
973
  Stdout contains JSON only; stderr shows the preview and exclusion reasons.
974
974
  `--out` requires a new file unless `--overwrite` is given, which atomically replaces an
975
- existing regular file; directories and symlinks are never replaced.
975
+ existing regular file; directories and symlinks are never replaced. Overwriting creates a
976
+ new file, so it does not keep the original's permissions, owner or hard links.
976
977
  Profiles contain canonical Git URLs with discovery inclusion, individual Skill
977
978
  selections with recorded version policies, Hosts and user methods, complete App
978
979
  recipes (including recipes for other platforms), and Package selections as
@@ -1,6 +1,6 @@
1
1
  import { PROJECT_HOSTS } from "./project-manifest.js";
2
2
  import { APP_RECIPE_SCHEMA } from "./app-schema.js";
3
- import { checkAppUpdates } from "./app-flow.js";
3
+ import { checkAppUpdates, declaredSetupHosts } from "./app-flow.js";
4
4
  import { NO_PROGRESS } from "./progress.js";
5
5
  import { CliUsageError } from "./usage-error.js";
6
6
  const ACTION_PROGRESS = {
@@ -158,15 +158,27 @@ async function runLifecycle(action, args, apps, output, usage, progress) {
158
158
  output(`Manual (never executed): ${result.manual}`);
159
159
  output(describeManualCompletion(plan));
160
160
  }
161
+ if (action === "install" && result.status === "installed")
162
+ showSetupHosts(plan);
161
163
  return result.status === "installed" || result.status === "untracked" || result.status === "completed" ? 0 : 1;
162
164
  }
165
+ function showSetupHosts(plan) {
166
+ const hosts = declaredSetupHosts(plan.entry.recipe);
167
+ if (!hosts.length)
168
+ return;
169
+ output(`Host setup is separate and not tracked; this recipe declares it for: ${hosts.join(", ")}.`);
170
+ output(`Run app setup ${quoteAppName(name)} --host <host> --yes for each Host you use.`);
171
+ }
172
+ }
173
+ function quoteAppName(name) {
174
+ return process.platform === "win32"
175
+ ? "'" + name.replaceAll("'", "''") + "'"
176
+ : "'" + name.replaceAll("'", "'\\''") + "'";
163
177
  }
164
178
  /** Copyable completion command for the platform's documented shell. */
165
179
  export function describeManualCompletion(plan) {
166
180
  const name = plan.entry.recipe.name;
167
- const quotedName = process.platform === "win32"
168
- ? "'" + name.replaceAll("'", "''") + "'"
169
- : "'" + name.replaceAll("'", "'\\''") + "'";
181
+ const quotedName = quoteAppName(name);
170
182
  const shell = process.platform === "win32" ? "PowerShell" : "POSIX shell";
171
183
  return `After completing it, run app ${plan.action} ${quotedName}${plan.host ? ` --host ${plan.host}` : ""} --manual-done --yes to check version (${shell}).`;
172
184
  }
@@ -7,6 +7,7 @@ import { fetchAppLatest } from "./app-latest.js";
7
7
  import { writeFileAtomically } from "./atomic-file.js";
8
8
  import { parseAppRecipe } from "./app-recipes.js";
9
9
  import { createPortableExecutableResolver } from "./executable-resolution.js";
10
+ import { PROJECT_HOSTS } from "./project-manifest.js";
10
11
  import { runProcess } from "./process-runner.js";
11
12
  import { defaultSourceStatePath } from "./source-state.js";
12
13
  const APP_OUTPUT_LIMIT = 1024 * 1024;
@@ -156,7 +157,14 @@ export function createAppOperations(environment = {}) {
156
157
  if (!entry.applicable)
157
158
  return { status: "not applicable here" };
158
159
  if (cached?.installedVersion) {
159
- return { status: "installed", installedVersion: cached.installedVersion, evidence: "recorded" };
160
+ const approval = await approvalStatus(entry);
161
+ if (approval.status === "invalid") {
162
+ return { status: "invalid", installedVersion: cached.installedVersion, evidence: "recorded", reason: approval.reason };
163
+ }
164
+ return {
165
+ status: "installed", installedVersion: cached.installedVersion, evidence: "recorded",
166
+ ...(approval.status === "needs approval" ? { needsApproval: true } : {}),
167
+ };
160
168
  }
161
169
  const approval = await approvalProblem(entry);
162
170
  if (approval)
@@ -482,6 +490,10 @@ export function createAppOperations(environment = {}) {
482
490
  }
483
491
  return { directory, approvalStatus, checkUpdate, load, preview, approve, inspect, inspectForExport, planLifecycle, executeLifecycle, completeManual, cancelManual, trackedApps, previewForget, forgetApp };
484
492
  }
493
+ /** Hosts whose user-global setup the recipe declares; install never runs them and wiring is not tracked. */
494
+ export function declaredSetupHosts(recipe) {
495
+ return PROJECT_HOSTS.filter(host => recipe.setup?.[host] !== undefined);
496
+ }
485
497
  /** Concurrent independent checks; one broken App must not hide the rest of a batch. */
486
498
  export async function checkAppUpdates(apps) {
487
499
  const entries = (await apps.load()).filter(entry => entry.applicable || entry.error);
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { rename, unlink, writeFile } from "node:fs/promises";
2
+ import { open, rename, unlink, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
3
4
  import { setTimeout as delay } from "node:timers/promises";
4
5
  export const WINDOWS_RENAME_ATTEMPTS = 20;
5
6
  const WINDOWS_RENAME_RETRY_MS = 5;
@@ -23,11 +24,35 @@ export async function renameWithWindowsRetry(from, to, deps = { rename, platform
23
24
  }
24
25
  }
25
26
  }
26
- /** Write a unique temporary sibling, then publish it with rename. */
27
+ /** Persist a directory entry change; Windows cannot open a directory for fsync. */
28
+ async function syncDirectory(directory) {
29
+ if (process.platform === "win32")
30
+ return;
31
+ const handle = await open(directory, "r");
32
+ try {
33
+ await handle.sync();
34
+ }
35
+ catch (error) {
36
+ // Some filesystems do not implement fsync on directories.
37
+ if (!["EINVAL", "ENOTSUP"].includes(error.code ?? ""))
38
+ throw error;
39
+ }
40
+ finally {
41
+ await handle.close();
42
+ }
43
+ }
44
+ /** Write a unique temporary sibling, fsync it, publish it with rename, then fsync the directory. */
27
45
  export async function writeFileAtomically(file, contents, options = {}) {
28
46
  const temporaryPath = `${file}.${process.pid}.${randomUUID()}.tmp`;
29
47
  try {
30
- await writeFile(temporaryPath, contents, { encoding: "utf8", flag: "wx", mode: options.mode });
48
+ const handle = await open(temporaryPath, "wx", options.mode);
49
+ try {
50
+ await handle.writeFile(contents, "utf8");
51
+ await handle.sync();
52
+ }
53
+ finally {
54
+ await handle.close();
55
+ }
31
56
  try {
32
57
  await renameWithWindowsRetry(temporaryPath, file);
33
58
  }
@@ -39,6 +64,7 @@ export async function writeFileAtomically(file, contents, options = {}) {
39
64
  // Source state holds its read-modify-write lock while using this Windows fallback.
40
65
  await writeFile(file, contents, "utf8");
41
66
  }
67
+ await syncDirectory(path.dirname(file));
42
68
  }
43
69
  finally {
44
70
  await unlink(temporaryPath).catch((error) => {
package/dist/src/cli.js CHANGED
@@ -155,6 +155,8 @@ const COMMANDS = new Map([
155
155
  // Loaded lazily so non-interactive commands never import Ink/React.
156
156
  const renderTui = dependencies.renderTui ?? (await import("./tui/render.js")).renderTui;
157
157
  await renderTui(operations, {
158
+ // The one place the opt-out is read: E2E tests start the real binary and cannot inject options.
159
+ refreshOnStart: process.env.AGENT_DEPOT_NO_BACKGROUND_REFRESH !== "1",
158
160
  homeDirectory: dependencies.homeDirectory,
159
161
  projectRoot,
160
162
  sourceAccess: dependencies.sourceAccess,
@@ -1,6 +1,9 @@
1
1
  const KIND_LABEL = { skill: "Skill", app: "App", package: "Package" };
2
- /** `Updating Skill tdd (2/5)…`; a batch of one has no position. */
2
+ /** `Removing demo (2/5)…`: the shared busy label of a batch; a batch of one has no position, and the `…` marks work in progress. */
3
+ export function formatStepLabel(text, position, total) {
4
+ return `${text}${total > 1 ? ` (${position}/${total})` : ""}…`;
5
+ }
6
+ /** `Updating Skill tdd (2/5)…`. */
3
7
  export function formatProgressLabel(event) {
4
- const position = event.total > 1 ? ` (${event.position}/${event.total})` : "";
5
- return `${event.verb} ${KIND_LABEL[event.kind]} ${event.name}${position}…`;
8
+ return formatStepLabel(`${event.verb} ${KIND_LABEL[event.kind]} ${event.name}`, event.position, event.total);
6
9
  }
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { lstat, mkdir, rename, rm, rmdir } from "node:fs/promises";
3
- import { homedir } from "node:os";
2
+ import { link, lstat, mkdir, readFile, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
3
+ import { homedir, hostname } from "node:os";
4
4
  import path from "node:path";
5
5
  import { isLockContention } from "./atomic-file.js";
6
6
  import { assertNoSymlinkPath } from "./path-safety.js";
@@ -91,6 +91,15 @@ const BACKGROUND_REFRESH_OPTIONS = Object.freeze({
91
91
  timeoutMs: 60_000,
92
92
  });
93
93
  const SOURCE_LOCK_RETRY_MS = 10;
94
+ const SOURCE_LOCK_OWNER_FILE = "owner.json";
95
+ const SOURCE_LOCK_OWNER_MAX_BYTES = 4096;
96
+ const SOURCE_RECOVERY_MUTEX_ID_FILE = "id";
97
+ const LOCK_REMOVAL_ATTEMPTS = 5;
98
+ const LOCK_REMOVAL_RETRY_MS = 50;
99
+ /** A recovery takes milliseconds, so a mutex this old was left by a process that died mid-recovery. */
100
+ const SOURCE_RECOVERY_MUTEX_MAX_AGE_MS = 60_000;
101
+ /** Covers locks from versions without an owner record and an acquisition interrupted before writing it. */
102
+ const SOURCE_LOCK_OWNERLESS_MAX_AGE_MS = 10 * 60_000;
94
103
  /**
95
104
  * Clones each Source into a local mirror and refreshes it from the registered
96
105
  * URL. Mirrors are intentionally not checked out: discovery owns later reads.
@@ -98,7 +107,9 @@ const SOURCE_LOCK_RETRY_MS = 10;
98
107
  export class GitSourceAccessAdapter {
99
108
  cachePath;
100
109
  runner;
110
+ lockHooks;
101
111
  constructor(options = {}) {
112
+ this.lockHooks = options.lockHooks ?? {};
102
113
  this.cachePath = options.cachePath ?? defaultGitSourceCachePath();
103
114
  this.runner = options.runner ?? new NodeGitCommandRunner();
104
115
  }
@@ -112,12 +123,14 @@ export class GitSourceAccessAdapter {
112
123
  await assertNoSymlinkPath(this.cachePath, "cache path");
113
124
  await mkdir(this.cachePath, { recursive: true });
114
125
  await assertNoSymlinkPath(this.cachePath, "cache path");
115
- const release = await acquireSourceLock(`${destination}.lock`);
126
+ const lock = await acquireSourceLock(`${destination}.lock`, this.lockHooks);
116
127
  try {
128
+ await this.lockHooks.afterLockAcquired?.();
129
+ await lock.assertHeld();
117
130
  await this.refreshLocked(source, destination, options.background ? BACKGROUND_REFRESH_OPTIONS : undefined);
118
131
  }
119
132
  finally {
120
- await release();
133
+ await lock.release();
121
134
  }
122
135
  }
123
136
  catch (error) {
@@ -441,14 +454,60 @@ async function pathType(candidate) {
441
454
  throw error;
442
455
  }
443
456
  }
444
- async function acquireSourceLock(lockPath) {
457
+ const LOCK_TAKEN_OVER_MESSAGE = "the Source cache lock was taken over by another process; this refresh stopped without touching the cache";
458
+ /** Identifies a directory across its lifetime; a recreated path gets a new inode or birth time. */
459
+ async function directoryIdentity(directory) {
460
+ try {
461
+ const { dev, ino, birthtimeNs } = await lstat(directory, { bigint: true });
462
+ return `${dev}:${ino}:${birthtimeNs}`;
463
+ }
464
+ catch (error) {
465
+ if (isMissing(error)) {
466
+ return undefined;
467
+ }
468
+ throw error;
469
+ }
470
+ }
471
+ /**
472
+ * Removes `directory` if `stillOurs` holds, then retries a failed removal only on that same directory.
473
+ * `rm` deletes the owner record before the directory, so after a partial failure ownership can no longer
474
+ * be re-read; identity stands in for it, and a successor's directory recreated at the path never matches.
475
+ * `rm`'s own retry option would repeat the removal on the path blindly instead. Windows reports EBUSY,
476
+ * EPERM or ENOTEMPTY while another process still holds a handle on something inside, so only those are retried.
477
+ * Residual limit: the checks and the removal are separate calls (the filesystem has no atomic conditional
478
+ * remove), so a successor that takes the path inside that window, microseconds wide, is still removed;
479
+ * two reclaimers of an aged-out recovery mutex can race the same way.
480
+ */
481
+ async function removeWhileOurs(directory, stillOurs, hooks) {
482
+ if (!(await stillOurs())) {
483
+ return;
484
+ }
485
+ const identity = await directoryIdentity(directory);
486
+ for (let attempt = 1;; attempt += 1) {
487
+ try {
488
+ // rm unlinks links instead of following them, so nothing outside the directory is touched.
489
+ await (hooks.removeDirectory ?? ((target) => rm(target, { recursive: true, force: true })))(directory);
490
+ return;
491
+ }
492
+ catch (error) {
493
+ const transient = ["EBUSY", "EPERM", "ENOTEMPTY"].includes(error.code ?? "");
494
+ if (!transient || attempt >= LOCK_REMOVAL_ATTEMPTS) {
495
+ throw error;
496
+ }
497
+ await new Promise((resolve) => setTimeout(resolve, LOCK_REMOVAL_RETRY_MS));
498
+ if (identity === undefined || (await directoryIdentity(directory)) !== identity) {
499
+ return;
500
+ }
501
+ }
502
+ }
503
+ }
504
+ async function acquireSourceLock(lockPath, hooks) {
445
505
  const deadline = Date.now() + SOURCE_LOCK_TIMEOUT_MS;
446
506
  while (true) {
447
507
  try {
448
508
  await mkdir(lockPath);
449
- return async () => {
450
- await rmdir(lockPath);
451
- };
509
+ await hooks.afterLockCreated?.();
510
+ return await publishLockOwner(lockPath, hooks);
452
511
  }
453
512
  catch (error) {
454
513
  if (!isLockContention(error)) {
@@ -457,13 +516,184 @@ async function acquireSourceLock(lockPath) {
457
516
  if ((await pathType(lockPath)) === "symlink") {
458
517
  throw new Error("the Source cache lock is a symbolic link");
459
518
  }
519
+ if (await recoverOrphanedLock(lockPath, hooks)) {
520
+ continue;
521
+ }
460
522
  if (Date.now() >= deadline) {
461
- throw new Error(`the Source cache lock directory is busy: ${lockPath}; another refresh might be active. Wait for it to finish before retrying`);
523
+ const record = await readLockOwner(lockPath);
524
+ const holder = record.kind === "owner"
525
+ ? ` (held by process ${record.owner.pid} on host ${record.owner.hostname})`
526
+ : record.kind === "none"
527
+ ? ` (held by an unknown process: it has no owner record and will be recovered once it is ${SOURCE_LOCK_OWNERLESS_MAX_AGE_MS / 60_000} minutes old)`
528
+ : "";
529
+ throw new Error(`the Source cache lock directory is busy: ${lockPath}${holder}; another refresh might be active. Wait for it to finish before retrying`);
462
530
  }
463
531
  await new Promise((resolve) => setTimeout(resolve, SOURCE_LOCK_RETRY_MS));
464
532
  }
465
533
  }
466
534
  }
535
+ /**
536
+ * Publishes the owner record without ever replacing one: a holder that stalled and lost its lock
537
+ * would otherwise overwrite its successor's record and let both believe they hold it.
538
+ * A hard link fails with EEXIST when the record exists. Where links are unsupported, an exclusive
539
+ * create gives the same no-overwrite guarantee, but a reader can briefly see a partial record,
540
+ * which it treats as unreadable and so never as a reason to remove the lock.
541
+ */
542
+ async function publishLockOwner(lockPath, hooks) {
543
+ const owner = { pid: process.pid, hostname: hostname(), startedAt: new Date().toISOString(), id: randomUUID() };
544
+ const ownerPath = path.join(lockPath, SOURCE_LOCK_OWNER_FILE);
545
+ const temporaryPath = `${ownerPath}.${owner.id}.tmp`;
546
+ const contents = JSON.stringify(owner);
547
+ try {
548
+ await writeFile(temporaryPath, contents, { encoding: "utf8", flag: "wx" });
549
+ try {
550
+ await link(temporaryPath, ownerPath);
551
+ }
552
+ catch (error) {
553
+ if (!["ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EPERM"].includes(error.code ?? "")) {
554
+ throw error;
555
+ }
556
+ await writeFile(ownerPath, contents, { encoding: "utf8", flag: "wx" });
557
+ }
558
+ }
559
+ catch (error) {
560
+ await unlink(temporaryPath).catch(() => undefined);
561
+ if (error.code === "EEXIST") {
562
+ throw new Error(LOCK_TAKEN_OVER_MESSAGE);
563
+ }
564
+ if (error.code === "ENOENT") {
565
+ // The directory this call created is gone: the lock was recovered, and a successor may already hold a new one.
566
+ throw new Error(LOCK_TAKEN_OVER_MESSAGE);
567
+ }
568
+ // rmdir only removes an empty directory: ours, or a successor's before it published, whose own write then
569
+ // reports the takeover. A successor with an owner record is never empty.
570
+ await rmdir(lockPath).catch(() => undefined);
571
+ throw new Error(`could not record the Source cache lock owner: ${error instanceof Error ? error.message : String(error)}`);
572
+ }
573
+ await unlink(temporaryPath).catch(() => undefined);
574
+ return {
575
+ async assertHeld() {
576
+ const record = await readLockOwner(lockPath);
577
+ if (record.kind !== "owner" || record.owner.id !== owner.id) {
578
+ throw new Error(LOCK_TAKEN_OVER_MESSAGE);
579
+ }
580
+ },
581
+ async release() {
582
+ await removeWhileOurs(lockPath, async () => {
583
+ const record = await readLockOwner(lockPath);
584
+ return record.kind === "owner" && record.owner.id === owner.id;
585
+ }, hooks);
586
+ },
587
+ };
588
+ }
589
+ /** Reads the owner record without following links. */
590
+ async function readLockOwner(lockPath) {
591
+ const ownerPath = path.join(lockPath, SOURCE_LOCK_OWNER_FILE);
592
+ try {
593
+ const information = await lstat(ownerPath);
594
+ if (!information.isFile() || information.size > SOURCE_LOCK_OWNER_MAX_BYTES) {
595
+ return { kind: "unreadable" };
596
+ }
597
+ const parsed = JSON.parse(await readFile(ownerPath, "utf8"));
598
+ if (typeof parsed !== "object" || parsed === null) {
599
+ return { kind: "unreadable" };
600
+ }
601
+ const { pid, hostname: host, startedAt, id } = parsed;
602
+ return Number.isInteger(pid) && pid > 0 && typeof host === "string" && typeof startedAt === "string" && typeof id === "string"
603
+ ? { kind: "owner", owner: { pid: pid, hostname: host, startedAt, id } }
604
+ : { kind: "unreadable" };
605
+ }
606
+ catch (error) {
607
+ return isMissing(error) ? { kind: "none" } : { kind: "unreadable" };
608
+ }
609
+ }
610
+ function isProcessAlive(pid) {
611
+ try {
612
+ process.kill(pid, 0);
613
+ return true;
614
+ }
615
+ catch (error) {
616
+ // EPERM means the process exists but belongs to someone else.
617
+ return error.code !== "ESRCH";
618
+ }
619
+ }
620
+ async function isOldLock(lockPath, maxAgeMs) {
621
+ try {
622
+ return Date.now() - (await lstat(lockPath)).mtimeMs > maxAgeMs;
623
+ }
624
+ catch {
625
+ return false;
626
+ }
627
+ }
628
+ /** The orphan a recoverer judged: a dead owner on this host (by id) or an old lock with no owner record. */
629
+ async function judgeOrphan(lockPath) {
630
+ const record = await readLockOwner(lockPath);
631
+ if (record.kind === "owner") {
632
+ return record.owner.hostname === hostname() && !isProcessAlive(record.owner.pid) ? record : undefined;
633
+ }
634
+ return record.kind === "none" && await isOldLock(lockPath, SOURCE_LOCK_OWNERLESS_MAX_AGE_MS) ? record : undefined;
635
+ }
636
+ /**
637
+ * Removes a lock whose holder is provably gone. Recoverers serialize on a short-lived `.recovering`
638
+ * mutex and re-judge the lock in place while holding it, so a judgment made before another process
639
+ * recovered and re-took the lock can never remove (or move) that live lock: nothing but a recoverer
640
+ * removes a lock whose owner is dead, and acquirers cannot replace a lock that still exists.
641
+ * Returns true when the caller should retry the acquisition at once.
642
+ */
643
+ async function recoverOrphanedLock(lockPath, hooks) {
644
+ const judged = await judgeOrphan(lockPath);
645
+ if (judged === undefined) {
646
+ return false;
647
+ }
648
+ await hooks.afterJudging?.();
649
+ const mutexPath = `${lockPath}.recovering`;
650
+ try {
651
+ await mkdir(mutexPath);
652
+ }
653
+ catch (error) {
654
+ if (!isLockContention(error)) {
655
+ throw error;
656
+ }
657
+ // A recoverer that died holding the mutex would otherwise block recovery for good.
658
+ if (await isOldLock(mutexPath, SOURCE_RECOVERY_MUTEX_MAX_AGE_MS)) {
659
+ await removeWhileOurs(mutexPath, () => isOldLock(mutexPath, SOURCE_RECOVERY_MUTEX_MAX_AGE_MS), hooks);
660
+ return true;
661
+ }
662
+ return false;
663
+ }
664
+ const mutexId = randomUUID();
665
+ const mutexIdPath = path.join(mutexPath, SOURCE_RECOVERY_MUTEX_ID_FILE);
666
+ const ownsMutex = async () => (await readFile(mutexIdPath, "utf8").catch(() => undefined)) === mutexId;
667
+ try {
668
+ await writeFile(mutexIdPath, mutexId, { encoding: "utf8", flag: "wx" });
669
+ }
670
+ catch (error) {
671
+ // The mutex was reclaimed before it was identified; give up this recovery attempt.
672
+ if (!["EEXIST", "ENOENT"].includes(error.code ?? "")) {
673
+ await rmdir(mutexPath).catch(() => undefined);
674
+ throw error;
675
+ }
676
+ return false;
677
+ }
678
+ try {
679
+ const isJudgedOrphan = async () => {
680
+ const current = await judgeOrphan(lockPath);
681
+ return current !== undefined && (current.kind === "owner"
682
+ ? judged.kind === "owner" && judged.owner.id === current.owner.id
683
+ : judged.kind === "none");
684
+ };
685
+ if (await isJudgedOrphan()) {
686
+ await hooks.beforeOrphanRemoval?.();
687
+ // A recoverer paused past the mutex's age limit may have had the mutex reclaimed and the lock re-taken,
688
+ // so the mutex and the orphan are re-checked before every removal attempt.
689
+ await removeWhileOurs(lockPath, async () => await ownsMutex() && await isJudgedOrphan(), hooks);
690
+ }
691
+ return true;
692
+ }
693
+ finally {
694
+ await removeWhileOurs(mutexPath, ownsMutex, hooks);
695
+ }
696
+ }
467
697
  export function validateGitRef(candidate) {
468
698
  if (typeof candidate !== "string" || candidate.length === 0 || candidate.trim() !== candidate ||
469
699
  Array.from(candidate).some((character) => (character.codePointAt(0) ?? 0) <= 0x20) ||
@@ -9,16 +9,19 @@ const INVISIBLE = /(?! )[\p{Cc}\p{Cf}\p{Zs}\p{Default_Ignorable_Code_Point}\u202
9
9
  function quoted(argument, terminal) {
10
10
  // The TUI escapes control and format characters once, when it draws; escaping them here too would show a real RLO as typed text.
11
11
  if (terminal)
12
- return quoteText(argument).replace(NOT_ESCAPED_BY_TUI, escapeCharacter);
12
+ return quoteText(argument);
13
13
  return JSON.stringify(argument).replace(INVISIBLE, escapeCharacter);
14
14
  }
15
15
  function escapeCharacter(character) {
16
16
  const code = character.codePointAt(0).toString(16);
17
17
  return code.length > 4 ? `\\u{${code}}` : `\\u${code.padStart(4, "0")}`;
18
18
  }
19
- /** `text` in double quotes with quotes and backslashes escaped; control characters stay as they are for the TUI to escape. */
19
+ /**
20
+ * `text` in double quotes with quotes and backslashes escaped. Control and format characters stay as they are for the TUI to escape;
21
+ * spaces other than U+0020 and ignorable characters are written out, as in `commandLine`.
22
+ */
20
23
  export function quoteText(text) {
21
- return `"${text.replace(/[\\"]/gu, "\\$&")}"`;
24
+ return `"${text.replace(/[\\"]/gu, "\\$&").replace(NOT_ESCAPED_BY_TUI, escapeCharacter)}"`;
22
25
  }
23
26
  /**
24
27
  * The argv as one shell-like line: arguments with spaces, quotes, shell metacharacters or invisible characters are JSON-quoted.
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Text } from "./safe-text.js";
3
+ import { formatStepLabel } from "../flow-progress.js";
3
4
  import { commandLine } from "../preview-model.js";
4
5
  import { PROJECT_HOSTS } from "../project-manifest.js";
5
6
  import { errorText, runBatch } from "./batch.js";
@@ -76,9 +77,9 @@ function appPreviewLines(mode) {
76
77
  function appBusy(label, previous, done) {
77
78
  return { kind: "app-busy", label, startedAt: operationStart(previous), ...(done?.length ? { done } : {}) };
78
79
  }
79
- /** `Running App step: name (2/5)…`; the position is left out for a batch of one. */
80
+ /** `Running App step: name (2/5)…`; `finished` counts the steps already done. */
80
81
  function stepLabel(verb, entry, finished, total) {
81
- return `${verb} App step: ${appName(entry)}${total > 1 ? ` (${finished + 1}/${total})` : ""}…`;
82
+ return formatStepLabel(`${verb} App step: ${appName(entry)}`, finished + 1, total);
82
83
  }
83
84
  async function prepareNext(context, requests, results = [], failures = 0, previous) {
84
85
  const { manage, apps } = context;
@@ -229,5 +230,5 @@ export function AppPanel({ mode }) {
229
230
  const styled = appPreviewLines(mode);
230
231
  const lines = styled.map(lineText);
231
232
  const chrome = appPreviewChrome(mode);
232
- return _jsx(PagedPreview, { chrome: chrome, lines: lines, styled: styled, viewport: geometry(lines, mode.cursor, chrome) });
233
+ return _jsx(PagedPreview, { lines: lines, styled: styled, viewport: geometry(lines, mode.cursor, chrome) });
233
234
  }
@@ -1,5 +1,6 @@
1
1
  import { PROJECT_HOSTS } from "../project-manifest.js";
2
2
  import { assertManagedInstallationRecordsUnchanged } from "../skill-removal.js";
3
+ import { formatStepLabel } from "../flow-progress.js";
3
4
  import { errorText, runBatch } from "./batch.js";
4
5
  import { missingHosts, prepareHostAddition, prepareUninstall, runHostAddition, runUninstall, UNINSTALL_REFUSALS, } from "./catalog-installs.js";
5
6
  import { answerPaged, answerYesNo, applyChecklistOutcome, hostChecklistKey } from "./mode-keys.js";
@@ -80,12 +81,12 @@ function resultOf(outcomes, skipped) {
80
81
  applied: outcomes.filter((outcome) => outcome.ok).map((outcome) => outcome.item.id),
81
82
  };
82
83
  }
83
- /** `Removing name (2/5)…` for each item as it starts. */
84
+ /** `Removing name (2/5)…` (no position for one item) for each item as it starts. */
84
85
  function startOf(verb, progress) {
85
86
  if (!progress)
86
87
  return undefined;
87
88
  return (item, position, total, finished) => {
88
- progress(`${verb} ${item.label} (${position}/${total})…`, finished.map(outcome => outcome.line));
89
+ progress(formatStepLabel(`${verb} ${item.label}`, position, total), finished.map(outcome => outcome.line));
89
90
  };
90
91
  }
91
92
  /** Execute step: each item is rechecked and applied on its own against the records the previous items were expected to leave. */
@@ -33,7 +33,7 @@ export function CatalogSelectionHelp({ row, height }) {
33
33
  /** The install confirmation with the shared footer; the view shows it alone so no tree rows count against its height. */
34
34
  export function InstallConfirmPanel({ mode }) {
35
35
  const { lines, chrome } = installPreview(mode.items);
36
- return _jsx(PagedPreview, { chrome: chrome, lines: lines, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
36
+ return _jsx(PagedPreview, { lines: lines, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
37
37
  }
38
38
  /** The prompt of the install flow; browsing shows nothing. */
39
39
  export function ActionPanel({ mode }) {
@@ -107,7 +107,7 @@ function actOnRow(context, snapshot, row, input, key) {
107
107
  else if (item.kind === "app") {
108
108
  if (input === "A")
109
109
  context.setMessage({ kind: "error", text: "Adoption applies to unmanaged Skills, not Apps" });
110
- else if (key.return && item.row.inspection.status !== "needs approval") {
110
+ else if (key.return && item.row.inspection.status !== "needs approval" && !item.row.inspection.needsApproval) {
111
111
  const { entry, inspection } = item.row;
112
112
  const approved = inspection.status === "installed" || inspection.status === "not installed";
113
113
  context.setMessage({
@@ -10,7 +10,7 @@ import { theme } from "./theme.js";
10
10
  /** An adoptable preview with the shared footer; the view shows it alone so no tree rows count against its height. */
11
11
  export function AdoptionConfirmPanel({ mode }) {
12
12
  const { lines, chrome } = adoptionPreview(mode);
13
- return _jsx(PagedPreview, { chrome: chrome, lines: lines, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
13
+ return _jsx(PagedPreview, { lines: lines, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
14
14
  }
15
15
  /** The prompt of the adoption and manage modes; browsing shows nothing. */
16
16
  export function ModePanel({ mode }) {
@@ -6,7 +6,7 @@ function AppDetails({ row }) {
6
6
  const version = row.inspection.installedVersion;
7
7
  const statusColor = row.inspection.status === "invalid" ? theme.error : theme.warning;
8
8
  const invalid = row.inspection.status === "invalid" ? ` · invalid recipe: ${row.inspection.reason ?? "unknown error"}` : "";
9
- return (_jsxs(Text, { children: [_jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: version === undefined ? statusColor : theme.version, children: version ?? row.inspection.status }), row.inspection.evidence === "recorded" ? _jsx(Text, { color: theme.value, children: " (recorded)" }) : null, invalid === "" ? null : _jsx(Text, { color: theme.error, children: invalid })] }));
9
+ return (_jsxs(Text, { children: [_jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: version === undefined ? statusColor : theme.version, children: version ?? row.inspection.status }), row.inspection.evidence === "recorded" ? _jsx(Text, { color: theme.value, children: " (recorded)" }) : null, row.inspection.needsApproval ? _jsx(Text, { color: theme.warning, children: " \u00B7 needs approval" }) : null, invalid === "" ? null : _jsx(Text, { color: theme.error, children: invalid })] }));
10
10
  }
11
11
  function PackageDetails({ row }) {
12
12
  const version = row.installedVersion;
@@ -79,7 +79,7 @@ export function describeNode(row) {
79
79
  return `${name} invalid recipe: ${inspection.reason}`;
80
80
  const version = inspection.installedVersion ?? inspection.status;
81
81
  const invalid = inspection.status === "invalid" ? ` · invalid recipe: ${inspection.reason}` : "";
82
- return `${name} ${version}${inspection.evidence === "recorded" ? " (recorded)" : ""}${invalid}`;
82
+ return `${name} ${version}${inspection.evidence === "recorded" ? " (recorded)" : ""}${inspection.needsApproval ? " · needs approval" : ""}${invalid}`;
83
83
  }
84
84
  return `${data.group.name} [${data.group.hosts.join(", ")}]`;
85
85
  }
@@ -26,18 +26,26 @@ function grouped(key) {
26
26
  groupedKeys.add(copy);
27
27
  return copy;
28
28
  }
29
+ /**
30
+ * The most events any hook has read this turn. A hook that mounts mid-read starts its own count at 0, but the hooks that were already
31
+ * listening have counted the keys it missed, so this tells it that it is not at the start of the turn.
32
+ */
33
+ let eventsInTurn = 0;
29
34
  /**
30
35
  * `useInput` that treats several plain characters delivered in one write (fast
31
36
  * typing, a paste) as the separate key presses they are, in order. Ink also hands
32
37
  * the escape sequences of one read over as separate events within one turn; every key
33
- * after the first of a turn counts as grouped, as do all characters of a split block.
38
+ * after the first of a turn counts as grouped, in any hook, as do all characters of a split block.
34
39
  */
35
40
  export function useKeys(handler) {
36
41
  const eventsThisTurn = useRef(0);
37
42
  useInput((input, key) => {
43
+ if (eventsInTurn === 0)
44
+ queueMicrotask(() => { eventsInTurn = 0; });
38
45
  if (eventsThisTurn.current++ === 0)
39
46
  queueMicrotask(() => { eventsThisTurn.current = 0; });
40
- const later = eventsThisTurn.current > 1;
47
+ eventsInTurn = Math.max(eventsInTurn, eventsThisTurn.current);
48
+ const later = eventsThisTurn.current > 1 || eventsInTurn > 1;
41
49
  if (ESCAPE_REMNANT.test(input)) {
42
50
  if (!PASTE_MARKERS.has(input))
43
51
  handler(input, later ? grouped(key) : key);
@@ -14,7 +14,7 @@ function SkippedLines({ skipped }) {
14
14
  /** A Skill confirmation with the shared footer; the view shows it alone so no tree rows count against its height. */
15
15
  export function PagedConfirmPanel({ mode }) {
16
16
  const { lines, styled, chrome } = pagedConfirmPreview(mode);
17
- return _jsx(PagedPreview, { chrome: chrome, lines: lines, styled: styled, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
17
+ return _jsx(PagedPreview, { lines: lines, styled: styled, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
18
18
  }
19
19
  /** Renders the prompt of a manage mode: host lists, previews and confirmations. */
20
20
  export function ManagePanel({ mode }) {
@@ -200,5 +200,5 @@ export function handlePackageKey(context, mode, input, key) {
200
200
  export function PackagePanel({ mode }) {
201
201
  const { lines, chrome } = packagePreview(mode);
202
202
  const viewport = usePreviewGeometry()(lines, mode.cursor, chrome);
203
- return _jsx(PagedPreview, { chrome: chrome, lines: lines, viewport: viewport });
203
+ return _jsx(PagedPreview, { lines: lines, viewport: viewport });
204
204
  }
@@ -5,7 +5,7 @@ import { displayPackageCoordinates, displayPackageSelection, } from "../package-
5
5
  export function packageVersionLabel(evidence, commit) {
6
6
  if (evidence === undefined)
7
7
  return "unknown";
8
- return evidence.kind === "manifest-version" ? versionWithCommit(evidence.version, commit) : evidence.commit.slice(0, 7);
8
+ return evidence.kind === "manifest-version" ? versionWithCommit(evidence.version, commit) : `commit ${evidence.commit.slice(0, 7)}`;
9
9
  }
10
10
  function versionWithCommit(version, commit) {
11
11
  return `${version}${commit === undefined ? "" : ` (${commit.slice(0, 7)})`}`;
@@ -47,9 +47,13 @@ export function recordedVersionLabel(record) {
47
47
  if (record.verified !== undefined)
48
48
  return packageVersionLabel(record.verified);
49
49
  if (record.lastDelegatedCommit !== undefined)
50
- return record.lastDelegatedCommit.slice(0, 7);
50
+ return `commit ${record.lastDelegatedCommit.slice(0, 7)}`;
51
51
  return "unknown";
52
52
  }
53
+ /** The version the host reports when it states one, else what the selection record remembers. */
54
+ export function installedVersionLabel({ installed, observed }) {
55
+ return observed?.kind === "installed" && observed.version !== undefined ? observed.version : recordedVersionLabel(installed);
56
+ }
53
57
  export function packageRow(record, inspection) {
54
58
  return {
55
59
  selection: record.selection,
@@ -58,7 +62,7 @@ export function packageRow(record, inspection) {
58
62
  ...(inspection.installed.kind === "installed" && inspection.installed.version !== undefined
59
63
  ? { installedVersion: versionWithCommit(inspection.installed.version, inspection.installed.commit) }
60
64
  : inspection.installed.kind === "installed" && inspection.installed.commit !== undefined
61
- ? { installedVersion: inspection.installed.commit.slice(0, 7) }
65
+ ? { installedVersion: `commit ${inspection.installed.commit.slice(0, 7)}` }
62
66
  : {}),
63
67
  available: packageVersionLabel(inspection.available, inspection.availableCommit),
64
68
  status: packageRowStatus(record.selection, inspection),
@@ -7,7 +7,9 @@ import { escapeControlText, sliceSpans } from "./preview-window.js";
7
7
  import { animationEnabled, formatElapsed, SPINNER_FRAMES, SPINNER_TICK_MS, STATIC_GLYPH } from "./working-model.js";
8
8
  /** A titled, color-independent boundary for a focused interaction or result. */
9
9
  export function BorderPanel({ title, children }) {
10
- return (_jsxs(Box, { borderStyle: "round", borderColor: theme.border, paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.title, bold: true, children: title }), children] }));
10
+ return (_jsxs(Box, { borderStyle: "round", borderColor: theme.border, paddingX: 1, flexDirection: "column", children: [typeof title === "string"
11
+ ? _jsx(Text, { color: theme.title, bold: true, children: title })
12
+ : title.map((row, position) => _jsx(PreEscapedText, { color: theme.title, bold: true, wrap: "truncate", children: row }, position)), children] }));
11
13
  }
12
14
  export function FieldLine({ label, value }) {
13
15
  return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: label }), _jsxs(Text, { color: theme.value, children: [" ", value] })] });
@@ -113,16 +115,22 @@ function noticeColor(line) {
113
115
  return theme.warning;
114
116
  return text.startsWith("Cannot install ") ? theme.error : undefined;
115
117
  }
116
- function PreviewLine({ line, position, logicalLine, offset = 0 }) {
118
+ /** The hanging prefix of a continuation row, drawn in the gutter colour, then the part of the line's spans the row shows. */
119
+ function rowSpans(spans, prefix, offset, length) {
120
+ return [...(prefix === "" ? [] : [previewSpan(prefix, theme.field)]), ...sliceSpans(spans, offset, length)];
121
+ }
122
+ function PreviewLine({ line, position, logicalLine, offset = 0, prefixLength = 0 }) {
123
+ const prefix = line.slice(0, prefixLength);
124
+ const textLength = line.length - prefixLength;
117
125
  const profileSpans = logicalLine === undefined ? profilePreviewSpans(line) : profilePreviewSpans(logicalLine);
118
126
  if (profileSpans) {
119
- const spans = logicalLine === undefined ? profileSpans : sliceSpans(profileSpans, offset, line.length);
127
+ const spans = logicalLine === undefined ? profileSpans : rowSpans(profileSpans, prefix, offset, textLength);
120
128
  return _jsx(PreEscapedText, { children: spans.map((span, index) => _jsx(PreEscapedText, { color: span.color, bold: span.bold, children: span.text }, index)) }, position);
121
129
  }
122
130
  const field = previewField(logicalLine ?? line);
123
131
  if (field) {
124
132
  const fieldSpans = [previewSpan(field.label, theme.field), previewSpan(field.value, theme.value)];
125
- const spans = logicalLine === undefined ? fieldSpans : sliceSpans(fieldSpans, offset, line.length);
133
+ const spans = logicalLine === undefined ? fieldSpans : rowSpans(fieldSpans, prefix, offset, textLength);
126
134
  return _jsx(PreEscapedText, { children: spans.map((span, index) => _jsx(PreEscapedText, { color: span.color, children: span.text }, index)) }, position);
127
135
  }
128
136
  return _jsx(PreEscapedText, { color: noticeColor(logicalLine ?? line) ?? theme.value, children: line }, position);
@@ -150,16 +158,16 @@ function footerColor(role, reachedEnd) {
150
158
  * A paged preview with the shared two-row footer: reading progress, then what `y` does. `[y]` stays
151
159
  * dimmed until `viewport` reaches the end; callers ignore `y` until then using the same viewport.
152
160
  */
153
- export function PagedPreview({ chrome, lines: rawLines, styled: rawStyled, viewport }) {
161
+ export function PagedPreview({ lines: rawLines, styled: rawStyled, viewport }) {
154
162
  // The same escape `previewViewport` applied, so rows, offsets and spans describe the same text. The rows themselves are already
155
163
  // escaped, hence `PreEscapedText`; only the title goes through `BorderPanel`.
156
164
  const lines = rawLines.map(escapeControlText);
157
165
  const styled = rawStyled?.map((line) => line?.map((span) => ({ ...span, text: escapeControlText(span.text) })));
158
- return (_jsxs(BorderPanel, { title: chrome.title, children: [viewport.rows.map((line, position) => {
166
+ return (_jsxs(BorderPanel, { title: viewport.titleRows, children: [viewport.rows.map((line, position) => {
159
167
  const logical = viewport.sourceLines[position];
160
168
  const custom = styled?.[logical];
161
169
  return custom === undefined
162
- ? _jsx(PreviewLine, { line: line, position: position, logicalLine: lines[logical], offset: viewport.sourceOffsets[position] }, position)
163
- : _jsx(PreEscapedText, { children: sliceSpans(custom, viewport.sourceOffsets[position], line.length).map((span, index) => _jsx(PreEscapedText, { color: span.color, bold: span.bold, children: span.text }, index)) }, position);
170
+ ? _jsx(PreviewLine, { line: line, position: position, logicalLine: lines[logical], offset: viewport.sourceOffsets[position], prefixLength: viewport.prefixLengths[position] }, position)
171
+ : _jsx(PreEscapedText, { children: rowSpans(custom, line.slice(0, viewport.prefixLengths[position]), viewport.sourceOffsets[position], line.length - viewport.prefixLengths[position]).map((span, index) => _jsx(PreEscapedText, { color: span.color, bold: span.bold, children: span.text }, index)) }, position);
164
172
  }), viewport.footer.map((spans, row) => _jsx(PreEscapedText, { children: spans.map((span, index) => _jsx(PreEscapedText, { ...footerColor(span.role, viewport.reachedEnd), children: span.text }, index)) }, `footer-${row}`))] }));
165
173
  }
@@ -1,20 +1,13 @@
1
1
  import { useWindowSize } from "ink";
2
2
  import stringWidth from "string-width";
3
3
  import { headerTabRowCount } from "./header-layout.js";
4
+ import { ESCAPE_TEXT, namesEscapedCharacter, textUnits, UNSAFE_PATTERN } from "./text-wrap.js";
4
5
  const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
5
6
  // Text shown in the TUI comes from recipes, Sources, Skills and Hosts. Raw, an ESC or carriage return redraws the terminal over the
6
7
  // lines under review and a bidi override reorders them; both also change the width the `y` gate was computed for. The zero-width joiner
7
8
  // stays because emoji sequences such as a person at a laptop are made with it.
8
- const UNSAFE_PATTERN = "(?!\\u200D)[\\p{Cc}\\p{Cf}\\u2028\\u2029]";
9
- const UNSAFE_CHARACTER = new RegExp(UNSAFE_PATTERN, "u");
10
9
  // A backslash run before text shaped like an escape we print (`\x1b`, `\u202e`, `\u{1f600}`), or before a character we escape.
11
- const ESCAPE_TEXT = "x[0-9a-f]{2}|u[0-9a-f]{4}|u\\{[0-9a-f]{1,6}\\}";
12
10
  const ESCAPABLE = new RegExp(`(\\\\+)(?:(${ESCAPE_TEXT})|(?=${UNSAFE_PATTERN}))|${UNSAFE_PATTERN}`, "giu");
13
- /** Whether `escape` (`x1b`, `u202e`, `u{1f600}`) names a character this module would print as an escape. */
14
- function namesEscapedCharacter(escape) {
15
- const code = Number.parseInt(escape.replace(/^[xu]\{?|\}$/gu, ""), 16);
16
- return code <= 0x10ffff && UNSAFE_CHARACTER.test(String.fromCodePoint(code));
17
- }
18
11
  /**
19
12
  * `text` with control, format and line-separator characters replaced by visible escapes (`\x1b`, `\u202e`). A backslash run right
20
13
  * before text that looks like one of those escapes is doubled, so a typed `\x1b` cannot pass for a real ESC; other backslashes,
@@ -41,26 +34,44 @@ function escapeChrome(chrome) {
41
34
  ...(chrome.cancel === undefined ? {} : { cancel: escapeControlText(chrome.cancel) }),
42
35
  };
43
36
  }
44
- function wrapLine(line, width) {
45
- const segments = [...graphemes.segment(line)];
37
+ const TREE_GUTTER = /^( *)([│┌├└])?( *)/u;
38
+ /**
39
+ * What continuation rows of a wrapped line start with: its indent and, in a step tree, the gutter that
40
+ * keeps running below its branch (`│` under `┌` and `├`, nothing under `└`). Empty when the prefix would
41
+ * leave less than half the width for text.
42
+ */
43
+ function hangingPrefix(line, width) {
44
+ const [, before = "", glyph = "", after = ""] = TREE_GUTTER.exec(line) ?? [];
45
+ const continued = glyph === "" ? "" : glyph === "└" ? " " : "│";
46
+ const prefix = `${before}${continued}${after}`;
47
+ return stringWidth(prefix) * 2 <= width ? prefix : "";
48
+ }
49
+ function wrapLine(line, width, hang = false) {
50
+ const segments = textUnits(line, width);
46
51
  if (segments.length === 0)
47
- return [{ row: "", offset: 0 }];
52
+ return [{ row: "", offset: 0, prefixLength: 0 }];
53
+ const prefix = hang ? hangingPrefix(line, width) : "";
48
54
  const wrapped = [];
49
55
  let row = "";
50
56
  let rowWidth = 0;
51
57
  let rowOffset = 0;
58
+ let prefixLength = 0;
59
+ let hasText = false;
52
60
  for (const { segment, index } of segments) {
53
61
  const segmentWidth = stringWidth(segment);
54
- if (row !== "" && rowWidth + segmentWidth > width) {
55
- wrapped.push({ row, offset: rowOffset });
56
- row = "";
57
- rowWidth = 0;
62
+ if (hasText && rowWidth + segmentWidth > width) {
63
+ wrapped.push({ row, offset: rowOffset, prefixLength });
64
+ row = prefix;
65
+ rowWidth = stringWidth(prefix);
66
+ prefixLength = prefix.length;
67
+ hasText = false;
58
68
  rowOffset = index;
59
69
  }
60
70
  row += segment;
61
71
  rowWidth += segmentWidth;
72
+ hasText = true;
62
73
  }
63
- wrapped.push({ row, offset: rowOffset });
74
+ wrapped.push({ row, offset: rowOffset, prefixLength });
64
75
  return wrapped;
65
76
  }
66
77
  const MAX_BAR_WIDTH = 12;
@@ -90,7 +101,8 @@ function footerSpans(state, chrome, width) {
90
101
  const progress = state.reachedEnd
91
102
  ? [{ text: "read ✓", role: "read" }]
92
103
  : [{ text: `read ${bar}${Math.floor(read * 100)}%`, role: "reading" }];
93
- progress.push({ text: ` · ${position(state.cursor + 1, state.start + 1, state.end)}`, role: "position" });
104
+ if (state.total > 0)
105
+ progress.push({ text: ` · ${position(state.cursor + 1, state.start + 1, state.end)}`, role: "position" });
94
106
  if (!state.reachedEnd)
95
107
  progress.push({ text: " · arrows/Pg page", role: "position" });
96
108
  else if (chrome.consequence !== undefined)
@@ -142,17 +154,37 @@ function reservedFooterRows(total, windowed, chrome, width, maxRows) {
142
154
  const widest = { cursor: Math.max(total - 1, 0), start: Math.max(total - 1, 0), end: total, total, windowed };
143
155
  return Math.max(layoutFooter(footerSpans({ ...widest, reachedEnd: false }, chrome, width), width, maxRows).length, layoutFooter(footerSpans({ ...widest, reachedEnd: true }, chrome, width), width, maxRows).length);
144
156
  }
157
+ /** `title` cut, with an ellipsis, to the longest start that wraps to at most `maxRows` once escaped. */
158
+ function clipTitle(title, width, maxRows) {
159
+ const fits = (text) => wrapLine(escapeControlText(text), width).length <= maxRows;
160
+ if (fits(title))
161
+ return title;
162
+ const parts = [...graphemes.segment(title)].map(({ segment }) => segment);
163
+ let low = 0;
164
+ let high = parts.length;
165
+ while (low < high) {
166
+ const middle = Math.ceil((low + high) / 2);
167
+ if (fits(`${parts.slice(0, middle).join("")}…`))
168
+ low = middle;
169
+ else
170
+ high = middle - 1;
171
+ }
172
+ return `${parts.slice(0, low).join("")}…`;
173
+ }
145
174
  export function previewViewport(rawLines, cursor, terminalRows, terminalColumns, rawChrome) {
146
175
  const lines = rawLines.map(escapeControlText);
147
176
  const chrome = escapeChrome(rawChrome);
148
177
  const contentWidth = Math.max(terminalColumns - 8, 1);
149
- const wrapped = lines.flatMap((line, sourceLine) => wrapLine(line, contentWidth).map(({ row, offset }) => ({ row, sourceLine, offset })));
178
+ const wrapped = lines.flatMap((line, sourceLine) => wrapLine(line, contentWidth, true).map((wrappedRow) => ({ ...wrappedRow, sourceLine })));
150
179
  const previewRows = wrapped.map(({ row }) => row);
151
180
  const total = previewRows.length;
152
181
  const headerRows = headerTabRowCount(contentWidth) + 3;
153
- const titleRows = wrapLine(chrome.title, contentWidth).length;
154
182
  const reservedRows = chrome.reservedRows !== undefined && chrome.reservedRows > 0 ? Math.ceil(chrome.reservedRows) : 0;
155
- const fixedRows = headerRows + 1 + 2 + titleRows + reservedRows;
183
+ // The footer, with its question and its action, is reserved first; the title takes what remains after a row of content.
184
+ const footerRows = Math.max(reservedFooterRows(total, false, chrome, contentWidth, Infinity), reservedFooterRows(total, true, chrome, contentWidth, Infinity));
185
+ const titleBudget = Math.max(terminalRows - headerRows - 1 - 2 - reservedRows - 1 - footerRows, 1);
186
+ const titleRows = wrapLine(escapeControlText(clipTitle(rawChrome.title, contentWidth, titleBudget)), contentWidth).map(({ row }) => row);
187
+ const fixedRows = headerRows + 1 + 2 + titleRows.length + reservedRows;
156
188
  // At least one preview row stays visible; the footer is cut to what remains.
157
189
  const maxFooterRows = Math.max(terminalRows - fixedRows - 1, 2);
158
190
  const rowsWithFooter = (windowed) => Math.max(terminalRows - fixedRows - reservedFooterRows(total, windowed, chrome, contentWidth, maxFooterRows), 1);
@@ -163,9 +195,11 @@ export function previewViewport(rawLines, cursor, terminalRows, terminalColumns,
163
195
  const end = Math.min(start + height, total);
164
196
  const reachedEnd = end === total;
165
197
  return {
198
+ titleRows,
166
199
  rows: previewRows.slice(start, end),
167
200
  sourceLines: wrapped.slice(start, end).map(({ sourceLine }) => sourceLine),
168
201
  sourceOffsets: wrapped.slice(start, end).map(({ offset }) => offset),
202
+ prefixLengths: wrapped.slice(start, end).map(({ prefixLength }) => prefixLength),
169
203
  start,
170
204
  end,
171
205
  total,
@@ -17,7 +17,7 @@ import { isYes, useKeys } from "./keys.js";
17
17
  import { escapeControlText, usePreviewGeometry } from "./preview-window.js";
18
18
  import { answerYesNo, moveCursor, movePreviewCursor, toggleChoice } from "./mode-keys.js";
19
19
  import { BorderPanel, PagedPreview, PreviewLines, Working } from "./panel-parts.js";
20
- import { clipText, elideMiddle, fitLines, wrapText } from "./text-wrap.js";
20
+ import { clipText, elideMiddle, fitLines, textUnits, wrapText } from "./text-wrap.js";
21
21
  import { theme } from "./theme.js";
22
22
  import { useMirrored, useMounted } from "./view-state.js";
23
23
  import { computeWindow, useListHeight } from "./window.js";
@@ -145,7 +145,7 @@ const NOTE_CHROME = { title: "Export note", cancel: "[Esc] close" };
145
145
  * what matter, so with fewer than three rows the field keeps its label and its tail behind a `…`; with
146
146
  * three or more a path keeps its first row too, around a count of the rows left out.
147
147
  */
148
- function NameField({ label, value, rows, width, tail = false }) {
148
+ export function NameField({ label, value, rows, width, tail = false }) {
149
149
  // Measured and drawn as escaped, so a hostile path or name takes the rows it is shown on.
150
150
  const escapedValue = escapeControlText(value);
151
151
  const wrapped = wrapText(`${label}${escapedValue}`, width);
@@ -155,9 +155,10 @@ function NameField({ label, value, rows, width, tail = false }) {
155
155
  if (keepsMiddleCount)
156
156
  shown = elideMiddle(wrapped, rows, width);
157
157
  else if (cut) {
158
- const characters = Array.from(escapedValue);
159
- for (let dropped = 1; dropped <= characters.length; dropped += 1) {
160
- shown = wrapText(`${label}…${characters.slice(dropped).join("")}`, width);
158
+ // Dropped by units, so the cut cannot leave half of a doubled backslash that would read as a real escape.
159
+ const units = textUnits(escapedValue).map(({ segment }) => segment);
160
+ for (let dropped = 1; dropped <= units.length; dropped += 1) {
161
+ shown = wrapText(`${label}…${units.slice(dropped).join("")}`, width);
161
162
  if (shown.length <= rows)
162
163
  break;
163
164
  }
@@ -640,5 +641,5 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
640
641
  return _jsxs(Box, { flexDirection: "column", children: [mode === "export-folder" ? _jsx(FilePicker, { purpose: "directory", listHeight: listHeight, startDirectory: exportDirectory ?? startDirectory, onCancel: () => setMode("export-list"), onChoose: selection => { if (selection.kind === "directory")
641
642
  chooseExportDirectory(selection.directory); } }) : null, mode === "import-browse" ? _jsx(FilePicker, { purpose: "file", listHeight: listHeight, startDirectory: importDirectory ?? startDirectory, notice: importNotice, onCancel: () => setMode("browse"), onChoose: chooseImportFile }) : null, listing ? _jsxs(BorderPanel, { title: mode === "export-list" ? "Export Profile" : "Import Profile", children: [listContent, _jsx(Text, { color: theme.legend, children: hintText }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null] }) : null, mode === "browse" ? _jsx(BorderPanel, { title: "Portable Profile", children: _jsx(Text, { color: theme.legend, children: "e export \u00B7 i import" }) }) : null, mode === "busy" && working ? _jsx(Working, { label: working.label, since: working.startedAt }) : null, mode === "export-name" ? _jsxs(BorderPanel, { title: "Export file name", children: [_jsx(NameField, { label: "Folder: ", value: exportDirectory ?? startDirectory, rows: folderRows, width: panelWidth }), _jsx(NameField, { label: "Name: ", value: `${text}_`, rows: nameRows, width: panelWidth, tail: true }), liveDestination === undefined ? null : _jsx(NameField, { label: "Destination: ", value: liveDestination, rows: destinationRows, width: panelWidth }), nameNote === undefined ? null : _jsx(PreEscapedText, { color: theme.warning, children: escapeControlText(nameNote) }), extensionNote === undefined ? null : _jsx(PreEscapedText, { color: theme.muted, children: escapeControlText(extensionNote) }), nameError === undefined ? null : nameErrorLines.length <= NAME_ERROR_ROWS
642
643
  ? _jsx(PreEscapedText, { color: theme.error, children: escapeControlText(nameError) })
643
- : elideMiddle(nameErrorLines, NAME_ERROR_ROWS, panelWidth).map((line, index) => _jsx(PreEscapedText, { color: theme.error, children: line }, index)), nameChecking === undefined ? null : _jsx(Working, { label: "Checking name\u2026", since: nameChecking }), _jsx(Text, { color: theme.legend, children: NAME_HINT })] }) : null, mode === "confirm" ? _jsx(PagedPreview, { chrome: confirmChrome(), lines: lines, viewport: previewGeometry(lines, cursor, confirmChrome()) }) : null, mode === "detail" ? _jsx(PagedPreview, { chrome: NOTE_CHROME, lines: lines, viewport: previewGeometry(lines, cursor, NOTE_CHROME) }) : null, mode === "results" ? _jsxs(_Fragment, { children: [_jsx(PreviewLines, { lines: lines.slice(window.start, window.end) }), _jsx(Text, { children: "j/k scroll" })] }) : null, window.indicator && mode === "results" ? _jsx(Text, { children: window.indicator }) : null] });
644
+ : elideMiddle(nameErrorLines, NAME_ERROR_ROWS, panelWidth).map((line, index) => _jsx(PreEscapedText, { color: theme.error, children: line }, index)), nameChecking === undefined ? null : _jsx(Working, { label: "Checking name\u2026", since: nameChecking }), _jsx(Text, { color: theme.legend, children: NAME_HINT })] }) : null, mode === "confirm" ? _jsx(PagedPreview, { lines: lines, viewport: previewGeometry(lines, cursor, confirmChrome()) }) : null, mode === "detail" ? _jsx(PagedPreview, { lines: lines, viewport: previewGeometry(lines, cursor, NOTE_CHROME) }) : null, mode === "results" ? _jsxs(_Fragment, { children: [_jsx(PreviewLines, { lines: lines.slice(window.start, window.end) }), _jsx(Text, { children: "j/k scroll" })] }) : null, window.indicator && mode === "results" ? _jsx(Text, { children: window.indicator }) : null] });
644
645
  }
@@ -5,14 +5,16 @@ import { App } from "./app.js";
5
5
  /** Renders the TUI and resolves once the user exits it. */
6
6
  export async function renderTui(operations, environment) {
7
7
  const refreshSession = createSourceRefreshSession(operations);
8
- const instance = render(_jsx(App, { operations: operations, environment: environment, refreshSession: refreshSession,
9
- // Test-only opt-out (tests/isolated-env.ts); manual and preview refreshes are unaffected.
10
- refreshOnStart: process.env.AGENT_DEPOT_NO_BACKGROUND_REFRESH !== "1" }), { alternateScreen: true });
8
+ const instance = render(_jsx(App, { operations: operations, environment: environment, refreshSession: refreshSession, refreshOnStart: environment?.refreshOnStart ?? true }), { alternateScreen: true });
11
9
  await instance.waitUntilExit();
10
+ await finishRunningRefreshes(refreshSession, (message) => process.stderr.write(message));
11
+ }
12
+ /** Drops the queued refreshes and waits for the running ones, telling the user only when it has to wait. */
13
+ export async function finishRunningRefreshes(refreshSession, write) {
12
14
  refreshSession.stop();
13
15
  // Killing a refresh would leave its Source cache lock behind, so wait for the running ones instead.
14
16
  if ([...refreshSession.snapshot().values()].some((state) => state.status === "refreshing")) {
15
- process.stderr.write("Waiting for the Source refreshes still running to finish…\n");
17
+ write("Waiting for the Source refreshes still running to finish…\n");
16
18
  await refreshSession.settled();
17
19
  }
18
20
  }
@@ -364,16 +364,16 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
364
364
  const panel = (_jsxs(Box, { flexDirection: "column", children: [loadError && !pagedPreview ? _jsxs(Text, { color: theme.error, children: ["Error loading App recipes: ", loadError] }) : null, error && !pagedPreview ? _jsx(Text, { color: theme.error, children: error }) : null, notice && !pagedPreview ? _jsx(Text, { color: theme.success, children: notice }) : null, mode.kind === "guide" ? (_jsxs(BorderPanel, { title: "Add an App recipe", children: [_jsx(Text, { children: "1 With the AI skill \u00B7 install if missing, then copy the prompt" }), _jsx(Text, { children: "2 Manual \u00B7 write a recipe using the schema" }), _jsx(Text, { children: "n or Esc closes" })] })) : null, mode.kind === "manual" ? (_jsxs(BorderPanel, { title: "Write an App recipe", children: [_jsxs(Text, { children: ["Write a JSON recipe in ", apps.directory] }), _jsx(Text, { children: "Schema: agent-depot app schema \u00B7 n or Esc closes" })] })) : null, mode.kind === "prompt" ? (_jsxs(BorderPanel, { title: "App recipe authoring prompt", children: [_jsx(Text, { children: "Use the agent-depot-apprecipe skill to create the recipe for <app>" }), _jsxs(Text, { children: ["Recipes: ", apps.directory, " \u00B7 n or Esc closes"] })] })) : null, mode.kind === "install" ? _jsx(ActionPanel, { mode: mode.action }) : null, mode.kind === "busy" ? _jsx(BorderPanel, { title: "App recipe workflow", children: _jsx(Working, { label: mode.label, since: mode.startedAt }) }) : null, mode.kind === "approve" ? (() => {
365
365
  const chrome = recipeApprovalChrome(mode.entry);
366
366
  const lines = mode.lines.map(lineText);
367
- return _jsx(PagedPreview, { chrome: chrome, lines: lines, styled: mode.lines, viewport: previewGeometry(lines, mode.cursor, chrome) });
367
+ return _jsx(PagedPreview, { lines: lines, styled: mode.lines, viewport: previewGeometry(lines, mode.cursor, chrome) });
368
368
  })() : null, mode.kind === "pick" ? (_jsxs(BorderPanel, { title: "Remove which recipe?", children: [mode.members.map((row, index) => _jsxs(Text, { children: [index + 1, " ", recipeMemberLine(row)] }, row.entry.file)), _jsx(Text, { color: theme.legend, children: "Press a number to review that one file for removal \u00B7 n or Esc cancels" })] })) : null, mode.kind === "uninstall-question" ? (_jsxs(BorderPanel, { title: "Uninstall before recipe removal", children: [_jsxs(Text, { children: ["App ", appName(mode.removal.entry), " is installed or tracked", mode.removal.installedVersion ? ` (${mode.removal.installedVersion})` : "", "."] }), _jsxs(Text, { children: [_jsx(DecisionPrompt, { children: "Uninstall it before removing the recipe?" }), _jsx(Text, { color: theme.legend, children: "(n cancels removal)" })] })] })) : null, mode.kind === "uninstall-confirm" || mode.kind === "uninstall-manual" ? (() => {
369
369
  const chrome = uninstallChrome(mode);
370
370
  const styled = appPlanLines(mode.plan);
371
371
  const lines = styled.map(lineText);
372
- return _jsx(PagedPreview, { chrome: chrome, lines: lines, styled: styled, viewport: previewGeometry(lines, mode.cursor, chrome) });
372
+ return _jsx(PagedPreview, { lines: lines, styled: styled, viewport: previewGeometry(lines, mode.cursor, chrome) });
373
373
  })() : null, mode.kind === "remove" ? (() => {
374
374
  const chrome = removeChrome(mode.removal);
375
375
  const lines = removeLines(mode.removal);
376
- return _jsx(PagedPreview, { chrome: chrome, lines: lines, viewport: previewGeometry(lines, mode.cursor, chrome) });
376
+ return _jsx(PagedPreview, { lines: lines, viewport: previewGeometry(lines, mode.cursor, chrome) });
377
377
  })() : null] }));
378
378
  return { rows, loadingSince, handleKey, startAdd, reload, clearMessage: () => { setError(undefined); setNotice(undefined); }, panel, directory: apps.directory, capturing: mode.kind !== "browse" };
379
379
  }
@@ -11,7 +11,6 @@ import { groupRecipeRows, useSourcesRecipes } from "./sources-recipes.js";
11
11
  import { BorderPanel, PagedPreview, Working } from "./panel-parts.js";
12
12
  import { usePreviewGeometry } from "./preview-window.js";
13
13
  import { movePreviewCursor } from "./mode-keys.js";
14
- import { formatAgo } from "./updates.js";
15
14
  import { animationEnabled, formatElapsed } from "./working-model.js";
16
15
  /** Footer keys for the selected row; App seeds the footer before the view reports a selection. */
17
16
  export function sourceRowHints(row) {
@@ -69,7 +68,7 @@ function RefreshMark({ state, now }) {
69
68
  case "refreshing":
70
69
  return _jsxs(Text, { color: theme.warning, children: ["\u2026 refreshing ", formatElapsed(now - (state.startedAt ?? now))] });
71
70
  case "refreshed":
72
- return _jsxs(Text, { color: theme.success, children: ["\u2713 refreshed ", formatAgo(now - (state.finishedAt ?? now))] });
71
+ return _jsx(Text, { color: theme.success, children: "\u2713 refreshed" });
73
72
  case "failed":
74
73
  return _jsx(Text, { color: theme.error, children: "\u2717 refresh failed" });
75
74
  }
@@ -408,7 +407,7 @@ function Prompt({ mode, previewGeometry }) {
408
407
  return _jsx(BorderPanel, { title: "Sources", children: _jsx(Working, { label: mode.label, since: mode.startedAt }) });
409
408
  if (mode.kind === "confirm" && previewGeometry !== undefined) {
410
409
  const { lines, chrome } = confirmPreview(mode);
411
- return _jsx(PagedPreview, { chrome: chrome, lines: lines, viewport: previewGeometry(lines, mode.cursor, chrome) });
410
+ return _jsx(PagedPreview, { lines: lines, viewport: previewGeometry(lines, mode.cursor, chrome) });
412
411
  }
413
412
  return null;
414
413
  }
@@ -1,11 +1,55 @@
1
1
  import stringWidth from "string-width";
2
2
  const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
3
+ // Control, format and line-separator characters the TUI prints as escapes; the zero-width joiner stays for emoji sequences.
4
+ export const UNSAFE_PATTERN = "(?!\\u200D)[\\p{Cc}\\p{Cf}\\u2028\\u2029]";
5
+ const UNSAFE_CHARACTER = new RegExp(UNSAFE_PATTERN, "u");
6
+ /** Text shaped like an escape the TUI prints (`x1b`, `u202e`, `u{1f600}`), ignoring case. */
7
+ export const ESCAPE_TEXT = "x[0-9a-f]{2}|u[0-9a-f]{4}|u\\{[0-9a-f]{1,6}\\}";
8
+ const ESCAPE_UNIT = new RegExp(`(\\\\+)(${ESCAPE_TEXT})`, "giu");
9
+ /** Whether `escape` (`x1b`, `u202e`, `u{1f600}`) names a character the TUI would print as an escape. */
10
+ export function namesEscapedCharacter(escape) {
11
+ const code = Number.parseInt(escape.replace(/^[xu]\{?|\}$/giu, ""), 16);
12
+ return code <= 0x10ffff && UNSAFE_CHARACTER.test(String.fromCodePoint(code));
13
+ }
14
+ /**
15
+ * The units text is cut at: graphemes, except around an escape written as text that names a character the TUI escapes. Cut inside,
16
+ * its doubled backslash would leave a `\x1b` that reads as a real ESC. The backslash run splits into pairs from the left, so a cut
17
+ * between pairs keeps its parity, and the last one or two backslashes stay with the escape text: a unit is at most 12 columns. A unit
18
+ * wider than `maxWidth` falls back to graphemes. `index` is the UTF-16 offset of the unit.
19
+ */
20
+ export function textUnits(value, maxWidth = Infinity) {
21
+ const units = [];
22
+ let from = 0;
23
+ const addGraphemes = (text, offset) => {
24
+ for (const { segment, index } of graphemes.segment(text))
25
+ units.push({ segment, index: offset + index });
26
+ };
27
+ for (const match of value.matchAll(ESCAPE_UNIT)) {
28
+ const [whole, backslashes = "", escape = ""] = match;
29
+ if (!namesEscapedCharacter(escape))
30
+ continue;
31
+ const tail = backslashes.length % 2 === 1 ? 1 : 2;
32
+ const pairs = backslashes.length - tail;
33
+ const unit = backslashes.slice(pairs) + escape;
34
+ addGraphemes(value.slice(from, match.index), from);
35
+ if (stringWidth(unit) > maxWidth)
36
+ addGraphemes(whole, match.index);
37
+ else {
38
+ for (let offset = 0; offset < pairs; offset += 2)
39
+ units.push({ segment: "\\\\", index: match.index + offset });
40
+ units.push({ segment: unit, index: match.index + pairs });
41
+ }
42
+ from = match.index + whole.length;
43
+ }
44
+ addGraphemes(value.slice(from), from);
45
+ return units;
46
+ }
3
47
  /** Split text on grapheme boundaries so wide and combining characters wrap at `width` columns. */
4
48
  export function wrapText(value, width) {
5
49
  const lines = [];
6
50
  let line = "";
7
51
  let lineWidth = 0;
8
- for (const { segment } of graphemes.segment(value)) {
52
+ for (const { segment } of textUnits(value, width)) {
9
53
  const segmentWidth = stringWidth(segment);
10
54
  if (line && lineWidth + segmentWidth > width) {
11
55
  lines.push(line);
@@ -26,7 +70,7 @@ export function fitLines(lines, maxRows, width) {
26
70
  const visible = lines.slice(0, maxRows);
27
71
  let last = "";
28
72
  let used = 0;
29
- for (const { segment } of graphemes.segment(visible[maxRows - 1])) {
73
+ for (const { segment } of textUnits(visible[maxRows - 1])) {
30
74
  const segmentWidth = stringWidth(segment);
31
75
  if (used + segmentWidth > width - 1)
32
76
  break;
@@ -55,7 +99,7 @@ export function clipText(value, width) {
55
99
  return value;
56
100
  let last = "";
57
101
  let used = 0;
58
- for (const { segment } of graphemes.segment(value)) {
102
+ for (const { segment } of textUnits(value)) {
59
103
  const segmentWidth = stringWidth(segment);
60
104
  if (used + segmentWidth > Math.max(width - 1, 0))
61
105
  break;
@@ -59,7 +59,7 @@ function CapturedUpdatePreview({ mode }) {
59
59
  const styled = updateModePreviewLines(mode);
60
60
  const lines = styled.map(lineText);
61
61
  const chrome = updateModePreviewChrome(mode);
62
- return _jsx(PagedPreview, { chrome: chrome, lines: lines, styled: styled, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
62
+ return _jsx(PagedPreview, { lines: lines, styled: styled, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
63
63
  }
64
64
  export function ModePanel({ mode }) {
65
65
  switch (mode.kind) {
@@ -16,6 +16,12 @@ import { lineText } from "./preview-render.js";
16
16
  import { updateModePreviewChrome, updateModePreviewLines } from "./updates-preview.js";
17
17
  import { useKeys } from "./keys.js";
18
18
  import { BorderPanel, Working } from "./panel-parts.js";
19
+ /** Whether the working panel is drawn. A preview owns the screen: its height was computed without the panel, so the two never share it. */
20
+ export function showsWorkingPanel(state, mode) {
21
+ if (mode.kind === "preview" || mode.kind === "manual" || mode.kind === "confirm-paths")
22
+ return false;
23
+ return state.status === "loading" || mode.kind === "busy";
24
+ }
19
25
  const NO_ENVIRONMENT = {};
20
26
  /** How often the age of the check is redrawn. */
21
27
  const CLOCK_TICK_MS = 10_000;
@@ -116,6 +122,6 @@ export function UpdatesView({ operations, environment, onCapturingChange, listHe
116
122
  const hasErrors = data?.appError !== undefined || (data?.results ?? []).some((result) => result.error !== undefined);
117
123
  const showingBrowse = mode.kind === "browse";
118
124
  // One indicator for the whole operation: a check's own phase label, else the busy mode's.
119
- const working = state.status === "loading" ? state : mode.kind === "busy" ? mode : undefined;
125
+ const working = !showsWorkingPanel(state, mode) ? undefined : state.status === "loading" ? state : mode.kind === "busy" ? mode : undefined;
120
126
  return (_jsxs(Box, { flexDirection: "column", children: [showingBrowse ? _jsxs(Text, { color: theme.title, bold: true, children: ["Updates (scope: ", scope, ") t all \u00B7 p project \u00B7 g user-global \u00B7 u update"] }) : null, working ? _jsx(BorderPanel, { title: state.status === "loading" ? "Update check" : "Updates", children: _jsx(Working, { label: working.label, since: working.startedAt, now: clock }) }) : null, showingBrowse && data ? _jsx(CheckStatus, { data: data, now: clock() }) : null, showingBrowse && data && !hasErrors && shown.length === 0 ? _jsx(Text, { children: "No installations" }) : null, showingBrowse ? entries.slice(window.start, window.end).map((entry, offset) => (_jsx(EntryLine, { entry: entry, selected: window.start + offset === index, checked: checked, view: view, showUnknown: showUnknown }, entryId(entry)))) : null, showingBrowse ? _jsx(UpdatesFooter, { indicator: window.indicator, upToDate: upToDate, updatableCount: updatable.length, checkedCount: checked.length, message: message }) : null, _jsx(ModePanel, { mode: mode })] }));
121
127
  }
@@ -5,7 +5,7 @@ import { sourceIdForUrl } from "../git-source.js";
5
5
  import { applyConfirmedUpdates, loadUpdates, previewUpdateCandidates, readScopeInstallations, skillUpdatePreviewItem, } from "../update-flow.js";
6
6
  import { packageOperationsOf } from "./environment.js";
7
7
  import { formatVersionEvidence } from "./installations.js";
8
- import { describePackageComponent, installedEvidenceLabel, packageStatusLabel, packageVersionLabel, recordedVersionLabel } from "./package-rows.js";
8
+ import { describePackageComponent, installedEvidenceLabel, packageStatusLabel, packageVersionLabel, installedVersionLabel, recordedVersionLabel } from "./package-rows.js";
9
9
  import { quoteText } from "../preview-model.js";
10
10
  /** Every scope the view can assess, in display order. */
11
11
  export const UPDATE_SCOPES = ["project", "user-global"];
@@ -213,7 +213,7 @@ function packagePreviewItem(row, { plan, approval }) {
213
213
  return {
214
214
  title: descriptor.name,
215
215
  detail: selection.host,
216
- version: { from: recordedVersionLabel(row.check.installed), to: packageVersionLabel(descriptor.version) },
216
+ version: { from: installedVersionLabel(row.check), to: packageVersionLabel(descriptor.version) },
217
217
  rows,
218
218
  };
219
219
  }
@@ -291,7 +291,7 @@ export async function prepareUpdates(data, selected) {
291
291
  groups, apps: data.apps, appPlans, appFailures,
292
292
  packages: data.packages, packagePlans, packageFailures,
293
293
  document: {
294
- action: `Apply ${counted(applicable.length, "update")}`,
294
+ action: applicable.length === 0 ? "No updates can be applied" : `Apply ${counted(applicable.length, "update")}`,
295
295
  summary: sections.map(({ items, noun }) => counted(items.length, noun)).join(" · "),
296
296
  context: [...context, ...[...environments].map((environment) => `Environment: ${environment}`)],
297
297
  warnings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iseoane/agent-depot",
3
- "version": "0.6.14",
3
+ "version": "0.6.16",
4
4
  "description": "A cross-platform catalog for reusable coding-agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "iseoane",
@@ -4,7 +4,7 @@ description: "Trigger: App recipe creation or repair. Research an App's actual i
4
4
  license: MIT
5
5
  metadata:
6
6
  author: "iseoane"
7
- version: "1.2"
7
+ version: "1.3"
8
8
  ---
9
9
 
10
10
  ## Activation Contract
@@ -43,6 +43,7 @@ Check installation **and** update, uninstall, version/latest and each Host setup
43
43
  - **Runtime/build and hook requirements:** verify documented engine ranges, compiler/toolchain minimums, architecture/libc support and utilities such as `git`, `curl`, `jq` or extraction tools. Check write access to the actual global prefix and required elevation; never assume sudo or request credentials in chat. Inspect prerequisites for Host plugins separately from the App binary.
44
44
  - **Unattended behavior:** use installed manager `--help`/documented help and upstream installer source to establish confirmation defaults and supported flags. Verify explicit formula/cask or package disambiguation where necessary. Do not invent `--yes` flags or rely on stdin EOF to count as consent. Run dry-run/read-only probes only after checking they do not install, register, authenticate or rewrite configuration; never test a lifecycle command just to see whether it prompts.
45
45
  - **Version and update signals:** verify a release-meaningful version (not an unchanged `dev`) and a latest signal for the chosen channel. A GitHub release can be ahead of a package-manager formula. Use channel metadata when available and disclose freshness limits; do not run `brew update` or another mutating refresh as a read-only latest probe.
46
+ - **Host wiring:** for each Host the user uses, read the installed App's setup help (for example `<app> setup --help`) and establish, per Host, whether setup runs unattended and which flags it accepts; a flag valid for one Host can be rejected for another. Inspect existing wiring that setup may conflict with or duplicate: user MCP entries (for Claude, `mcpServers` in `~/.claude.json`), Host plugins (`claude plugin list`), Host packages (`pi list`) and permissions entries. Setup that refuses to overwrite a conflicting entry needs a documented removal step first. Flags that grant permissions or bypass prompts are the user's choice: present them, never select them silently.
46
47
  - **Preserved state:** identify existing binary copies, data directories and Host wiring without reading private memory content. Explain migrations, backups and teardown scope outside executable argv where appropriate; never delete user data or remove a shared tap/marketplace implicitly.
47
48
 
48
49
  ### Homebrew checklist
@@ -59,10 +60,10 @@ Read the current schema; do not assume it has dependency or multi-command fields
59
60
 
60
61
  1. Record upstream evidence and platform support. Inspect all PATH matches (`which -a`/`whence -a`, or Windows `Get-Command -All`), resolved paths, and channel inventories such as `brew list`, `npm ls -g` or `pipx list`. List Windows shims seen from WSL separately: `/mnt/<drive>/` executables cannot serve a Linux recipe. Finish only when the actual channel is established or the user has chosen one.
61
62
  2. Complete the Installer Preflight and resolve the prerequisite decision gate before writing required `name`, `install`, `update`, `uninstall`, and `version`. Each lifecycle step uses `argv`, `manual`, or both; manual fallback applies only to spawn failure, never non-zero exit. `version` needs read-only argv and a JavaScript regex with exactly one capture group. Choose optional `latest` as `github: owner/repo`, `npm: package`, or read-only `argv` plus `pattern`; omit it when unknown. Consult [the illustrative example](assets/example.md), not as upstream command evidence.
62
- 3. Put user-global Host wiring in optional `setup`/`teardown` maps keyed by `pi`, `claude`, `codex`, or `opencode`. Keep install separate; uninstall never implicitly runs teardown. Delegate wiring to App commands or manual user instructions, not direct agent edits of Host configuration. Exclude project initialization.
63
+ 3. Ask which Hosts the user uses when it is not already established. Put user-global Host wiring in optional `setup`/`teardown` maps keyed by `pi`, `claude`, `codex`, or `opencode`. Keep install separate; uninstall never implicitly runs teardown. Agent Depot never executes `manual` text and does not track wiring, so a manual setup stays undone unless the user runs it: use `argv` whenever the Host Wiring preflight shows an unattended route, and keep `manual` only for steps that need a terminal or a user decision. When repairing a recipe or after an App update, recheck every `manual` setup step against the installed version's help; a new non-interactive flag can make it automatable. Delegate wiring to App commands or manual user instructions, not direct agent edits of Host configuration. Exclude project initialization.
63
64
  4. Declare optional `skills` only for documented App-owned directory/link names. Match whole names case-sensitively; only `*` is special, while `?` and brackets are literal. Reject empty/whitespace-only, path separators, NUL and star-only patterns. These declarations filter unmanaged adoption/removal even before approval/install; they verify no artifacts or provenance.
64
65
  5. Run `agent-depot app validate <file>...` for current-platform recipes. Fix structural errors; report unapproved recipes as needs approval, not runtime-verified. Distinguish structural validity, recipe approval, prerequisite readiness, installed version and Host wiring; none proves the others. A known missing prerequisite remains a blocker even when validation succeeds. Version commands run only for approved applicable content. Mark other platforms unverified. After user approval, the user can inspect `app list` and separately preview `app install|update|uninstall <name>` or `app setup|teardown <name> --host <host>`.
65
66
 
66
67
  ## Output Contract
67
68
 
68
- Return file paths, platform/channel evidence, a concise prerequisite status (satisfied/missing/unverified) with preparation commands and whether the recipe is ready or blocked, validation results and runtime-verification limits; unsupported platforms; omitted optional fields and why; license, telemetry/update-check behavior (or unknown); and conflicting duplicate install routes, including plugin-marketplace versus setup routes. Explain that version alone determines installed state, updates require a changed version, and unknown latest is not an available update. End with the user's approval command, without running it.
69
+ Return file paths, platform/channel evidence, a per-Host wiring status for each Host the user uses (wired, pending, manual or conflicting, with the evidence inspected) and the exact `agent-depot app setup <name> --host <host>` command for each pending Host, a concise prerequisite status (satisfied/missing/unverified) with preparation commands and whether the recipe is ready or blocked, validation results and runtime-verification limits; unsupported platforms; omitted optional fields and why; license, telemetry/update-check behavior (or unknown); and conflicting duplicate install routes, including plugin-marketplace versus setup routes. Explain that version alone determines installed state, updates require a changed version, and unknown latest is not an available update. End with the user's approval command, without running it.