portable-agent-layer 0.63.3 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +8 -4
  2. package/assets/schema/pal-settings.schema.json +4 -0
  3. package/assets/skills/analyze-pdf/SKILL.md +11 -0
  4. package/assets/skills/analyze-youtube/SKILL.md +12 -0
  5. package/assets/skills/consulting-report/SKILL.md +9 -0
  6. package/assets/skills/consulting-report/tools/generate-pdf.mjs +2 -2
  7. package/assets/skills/consulting-report/tools/generate-pdf.ts +5 -2
  8. package/assets/skills/council/SKILL.md +32 -0
  9. package/assets/skills/create-pdf/SKILL.md +13 -0
  10. package/assets/skills/create-skill/SKILL.md +14 -2
  11. package/assets/skills/create-skill/authoring-guide.md +10 -1
  12. package/assets/skills/create-subagent/SKILL.md +22 -4
  13. package/assets/skills/{research → deep-research}/SKILL.md +32 -1
  14. package/assets/skills/entities/SKILL.md +10 -0
  15. package/assets/skills/extract-wisdom/SKILL.md +12 -0
  16. package/assets/skills/first-principles/SKILL.md +8 -0
  17. package/assets/skills/frontend-design/SKILL.md +14 -0
  18. package/assets/skills/fyzz-chat-api/SKILL.md +10 -0
  19. package/assets/skills/humanize/SKILL.md +13 -1
  20. package/assets/skills/opinion/SKILL.md +11 -0
  21. package/assets/skills/pal-analyze/SKILL.md +11 -0
  22. package/assets/skills/pal-reflect/SKILL.md +10 -0
  23. package/assets/skills/playwright/SKILL.md +15 -2
  24. package/assets/skills/playwright/tools/shot.ts +6 -7
  25. package/assets/skills/presentation/SKILL.md +12 -0
  26. package/assets/skills/projects/SKILL.md +20 -1
  27. package/assets/skills/reflect/SKILL.md +13 -0
  28. package/assets/skills/telos/SKILL.md +12 -0
  29. package/assets/skills/think/SKILL.md +9 -0
  30. package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +3 -0
  31. package/assets/templates/pal-settings.json +1 -0
  32. package/assets/templates/settings.claude.json +2 -1
  33. package/package.json +15 -4
  34. package/src/cli/index.ts +95 -9
  35. package/src/cli/migrate.ts +69 -3
  36. package/src/cli/skill.ts +47 -3
  37. package/src/hooks/handlers/inject-retrieval.ts +20 -10
  38. package/src/hooks/lib/anchor.ts +90 -0
  39. package/src/hooks/lib/bindings.ts +117 -0
  40. package/src/hooks/lib/export.ts +38 -1
  41. package/src/hooks/lib/import-merge.ts +220 -0
  42. package/src/hooks/lib/inference.ts +113 -72
  43. package/src/hooks/lib/machine.ts +176 -0
  44. package/src/hooks/lib/projects.ts +223 -15
  45. package/src/hooks/lib/readme-sync.ts +30 -10
  46. package/src/hooks/lib/relationship.ts +3 -1
  47. package/src/hooks/lib/remote.ts +58 -0
  48. package/src/hooks/lib/retrieval.ts +8 -2
  49. package/src/hooks/lib/signals.ts +2 -1
  50. package/src/hooks/lib/skill-match.ts +129 -0
  51. package/src/hooks/lib/skill-triggers.ts +82 -0
  52. package/src/hooks/lib/stop.ts +5 -2
  53. package/src/targets/lib.ts +137 -35
  54. package/src/targets/opencode/plugin.ts +2 -6
  55. package/src/tools/agent/algorithm-reflect.ts +45 -11
  56. package/src/tools/agent/project.ts +148 -23
  57. package/src/tools/agent/thread.ts +7 -2
  58. package/src/tools/skill-doctor.ts +130 -5
  59. package/assets/skills/playwright/tools/shot-lib.mjs +0 -44
  60. package/assets/skills/playwright/tools/shot.mjs +0 -89
  61. package/assets/skills/review/SKILL.md +0 -20
  62. package/assets/skills/summarize/SKILL.md +0 -16
  63. /package/assets/skills/{research → deep-research}/tools/gemini-search.ts +0 -0
  64. /package/assets/skills/{research → deep-research}/tools/grok-search.ts +0 -0
  65. /package/assets/skills/{research → deep-research}/tools/perplexity-search.ts +0 -0
@@ -20,7 +20,9 @@
20
20
  * yet wired and currently fall through to the API path.
21
21
  */
22
22
 
23
- import { basename } from "node:path";
23
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
24
+ import { tmpdir } from "node:os";
25
+ import { basename, join } from "node:path";
24
26
  import {
25
27
  getActiveAgent,
26
28
  isClaude,
@@ -151,14 +153,14 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
151
153
  "inference",
152
154
  `${tag} route=claude-spawn agent=${agent} model=${opts.model ?? HAIKU_MODEL}`
153
155
  );
154
- return inferenceViaCliSpawn(bin, buildClaudeArgs(opts), opts.user, opts);
156
+ return inferenceViaClaudeSpawn(bin, opts);
155
157
  }
156
158
  }
157
159
  if (isCodex()) {
158
160
  const bin = getCodexBinary();
159
161
  if (bin) {
160
162
  logDebug("inference", `${tag} route=codex-spawn agent=${agent}`);
161
- return inferenceViaCliSpawn(bin, buildCodexArgs(opts), "", opts);
163
+ return inferenceViaCliSpawn(bin, buildCodexArgs(opts), buildCliPrompt(opts), opts);
162
164
  }
163
165
  }
164
166
  if (isCodex() && hasOpenAiKey()) {
@@ -172,7 +174,7 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
172
174
  return inferenceViaCliSpawn(
173
175
  bin,
174
176
  buildOpencodeArgs(opts),
175
- "",
177
+ buildCliPrompt(opts),
176
178
  opts,
177
179
  extractOpencodeText
178
180
  );
@@ -182,14 +184,19 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
182
184
  const bin = getCopilotBinary();
183
185
  if (bin) {
184
186
  logDebug("inference", `${tag} route=copilot-spawn agent=${agent}`);
185
- return inferenceViaCliSpawn(bin, buildCopilotArgs(opts), "", opts);
187
+ return inferenceViaCliSpawn(
188
+ bin,
189
+ buildCopilotArgs(opts),
190
+ buildCliPrompt(opts),
191
+ opts
192
+ );
186
193
  }
187
194
  }
188
195
  if (isCursor()) {
189
196
  const bin = getCursorBinary();
190
197
  if (bin) {
191
198
  logDebug("inference", `${tag} route=cursor-spawn agent=${agent}`);
192
- return inferenceViaCliSpawn(bin, buildCursorArgs(opts), "", opts);
199
+ return inferenceViaCliSpawn(bin, buildCursorArgs(opts), buildCliPrompt(opts), opts);
193
200
  }
194
201
  }
195
202
  if (hasApiKey()) {
@@ -284,16 +291,23 @@ export function _resetCursorBinaryCache(): void {
284
291
  cursorBinaryCache = undefined;
285
292
  }
286
293
 
287
- /** Build the argv for `claude --print …` from inference options. Pure. */
288
- export function buildClaudeArgs(opts: InferenceOptions): string[] {
289
- const model = opts.model ?? HAIKU_MODEL;
290
- const system = opts.jsonSchema
291
- ? injectJsonSchemaInstruction(opts.system ?? "", opts.jsonSchema)
292
- : opts.system;
294
+ /**
295
+ * Build the argv for `claude --print …`. Pure.
296
+ *
297
+ * `--system-prompt` is deliberately absent: PAL's system prompts run to several
298
+ * paragraphs, and an argv element cannot carry a newline on Windows once
299
+ * Bun.spawn resolves claude to its .cmd shim and cmd.exe re-parses the command
300
+ * line. System, user and any JSON-schema instruction all travel together on
301
+ * stdin instead, the same way every other agent receives them.
302
+ */
303
+ export function buildClaudeArgs(
304
+ opts: InferenceOptions,
305
+ systemPromptFile?: string
306
+ ): string[] {
293
307
  const args = [
294
308
  "--print",
295
309
  "--model",
296
- model,
310
+ opts.model ?? HAIKU_MODEL,
297
311
  "--tools",
298
312
  "",
299
313
  "--output-format",
@@ -301,12 +315,38 @@ export function buildClaudeArgs(opts: InferenceOptions): string[] {
301
315
  "--setting-sources",
302
316
  "",
303
317
  ];
304
- if (system) {
305
- args.push("--system-prompt", system);
306
- }
318
+ if (systemPromptFile) args.push("--system-prompt-file", systemPromptFile);
307
319
  return args;
308
320
  }
309
321
 
322
+ /**
323
+ * Claude keeps a real system prompt, unlike the other agents, so the system text
324
+ * reaches it through --system-prompt-file rather than --system-prompt. Only the
325
+ * path travels in argv, which is what makes this work on Windows: an argv
326
+ * element cannot carry a newline once Bun.spawn resolves claude to its .cmd
327
+ * shim, and PAL's system prompts run to several paragraphs. Folding the system
328
+ * text into the user message instead is not an option — Claude treats
329
+ * instructions embedded in message content as an injection attempt and refuses.
330
+ */
331
+ async function inferenceViaClaudeSpawn(
332
+ bin: string,
333
+ opts: InferenceOptions
334
+ ): Promise<InferenceResult> {
335
+ const system = opts.jsonSchema
336
+ ? injectJsonSchemaInstruction(opts.system ?? "", opts.jsonSchema)
337
+ : opts.system;
338
+ if (!system) return inferenceViaCliSpawn(bin, buildClaudeArgs(opts), opts.user, opts);
339
+
340
+ const dir = await mkdtemp(join(tmpdir(), "pal-system-"));
341
+ try {
342
+ const file = join(dir, "system-prompt.md");
343
+ await writeFile(file, system, "utf-8");
344
+ return await inferenceViaCliSpawn(bin, buildClaudeArgs(opts, file), opts.user, opts);
345
+ } finally {
346
+ await rm(dir, { recursive: true, force: true });
347
+ }
348
+ }
349
+
310
350
  /**
311
351
  * Build the argv for `codex exec …` from inference options. Pure.
312
352
  *
@@ -316,20 +356,16 @@ export function buildClaudeArgs(opts: InferenceOptions): string[] {
316
356
  * --sandbox read-only → child cannot execute shell commands even if it tries
317
357
  * --ephemeral → no session persistence; one-shot only
318
358
  *
319
- * Codex has no --system-prompt equivalent the full prompt is a single positional
320
- * argv string. We concatenate system + user + JSON-schema instruction into one
321
- * prompt. ARG_MAX is ~256KB on macOS; typical PAL prompts are 1-2KB.
359
+ * Codex has no --system-prompt equivalent, so system + user + JSON-schema become
360
+ * one prompt. That prompt goes in on stdin, not as a positional argument, because
361
+ * a PAL prompt spans several paragraphs and an argv element cannot carry a
362
+ * newline on Windows: Bun.spawn resolves the CLI to its .cmd shim, cmd.exe
363
+ * re-parses the command line, and the child exits non-zero having written
364
+ * nothing. Codex reads its instructions from stdin when no positional prompt is
365
+ * given, so this costs nothing on POSIX and is the only thing that works on
366
+ * Windows. Do not move the prompt back into argv.
322
367
  */
323
- export function buildCodexArgs(opts: InferenceOptions): string[] {
324
- const parts: string[] = [];
325
- if (opts.system) parts.push(opts.system);
326
- parts.push(opts.user);
327
- if (opts.jsonSchema) {
328
- parts.push(
329
- `Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${JSON.stringify(opts.jsonSchema)}`
330
- );
331
- }
332
- const prompt = parts.join("\n\n");
368
+ export function buildCodexArgs(_opts: InferenceOptions): string[] {
333
369
  return [
334
370
  "exec",
335
371
  "--color",
@@ -340,7 +376,6 @@ export function buildCodexArgs(opts: InferenceOptions): string[] {
340
376
  "--sandbox",
341
377
  "read-only",
342
378
  "--ephemeral",
343
- prompt,
344
379
  ];
345
380
  }
346
381
 
@@ -354,21 +389,14 @@ export function buildCodexArgs(opts: InferenceOptions): string[] {
354
389
  * text via extractOpencodeText() rather than wading through
355
390
  * decoration ("> build · provider/model" banner etc).
356
391
  *
357
- * opencode (like codex) has no --system-prompt equivalent the full prompt is
358
- * the positional message argv. System + user + JSON-schema are concatenated.
359
- * Provider/model is left unset so opencode uses the user's configured default.
392
+ * opencode (like codex) has no --system-prompt equivalent, so system + user +
393
+ * JSON-schema are concatenated and delivered on stdin rather than as the
394
+ * positional message, for the same reason as codex: a multi-paragraph argv
395
+ * element does not survive cmd.exe on Windows. Provider/model is left unset so
396
+ * opencode uses the user's configured default.
360
397
  */
361
- export function buildOpencodeArgs(opts: InferenceOptions): string[] {
362
- const parts: string[] = [];
363
- if (opts.system) parts.push(opts.system);
364
- parts.push(opts.user);
365
- if (opts.jsonSchema) {
366
- parts.push(
367
- `Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${JSON.stringify(opts.jsonSchema)}`
368
- );
369
- }
370
- const prompt = parts.join("\n\n");
371
- return ["run", "--pure", "--format", "json", prompt];
398
+ export function buildOpencodeArgs(_opts: InferenceOptions): string[] {
399
+ return ["run", "--pure", "--format", "json"];
372
400
  }
373
401
 
374
402
  /**
@@ -385,23 +413,16 @@ export function buildOpencodeArgs(opts: InferenceOptions): string[] {
385
413
  * of running inference. Safe to pair with --mode ask
386
414
  * because that mode disallows tool calls anyway.
387
415
  *
388
- * cursor-agent has no --system-prompt flag system + user + JSON-schema are
389
- * concatenated into a single positional prompt argument.
416
+ * cursor-agent has no --system-prompt flag, so system + user + JSON-schema are
417
+ * concatenated into one prompt delivered on stdin rather than as the trailing
418
+ * positional argument: a multi-paragraph argv element cannot survive cmd.exe on
419
+ * Windows. `-p` stays because for cursor-agent it means --print, not --prompt.
390
420
  *
391
421
  * Auth note: cursor-agent picks up either `cursor-agent login` credentials or
392
422
  * `CURSOR_API_KEY` env var. PAL doesn't manage these — that's the user's setup.
393
423
  */
394
- export function buildCursorArgs(opts: InferenceOptions): string[] {
395
- const parts: string[] = [];
396
- if (opts.system) parts.push(opts.system);
397
- parts.push(opts.user);
398
- if (opts.jsonSchema) {
399
- parts.push(
400
- `Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${JSON.stringify(opts.jsonSchema)}`
401
- );
402
- }
403
- const prompt = parts.join("\n\n");
404
- return ["-p", "--mode", "ask", "--output-format", "text", "--trust", prompt];
424
+ export function buildCursorArgs(_opts: InferenceOptions): string[] {
425
+ return ["-p", "--mode", "ask", "--output-format", "text", "--trust"];
405
426
  }
406
427
 
407
428
  /**
@@ -417,22 +438,14 @@ export function buildCursorArgs(opts: InferenceOptions): string[] {
417
438
  * --allow-all-tools → REQUIRED for non-interactive mode (without it,
418
439
  * copilot prompts for tool-use confirmation)
419
440
  *
420
- * Copilot has no --system-prompt flag system + user + JSON-schema are
421
- * concatenated into a single prompt passed via -p.
441
+ * Copilot has no --system-prompt flag, so system + user + JSON-schema are
442
+ * concatenated into one prompt delivered on stdin. `-p/--prompt` is deliberately
443
+ * absent: it takes the prompt inline, and a multi-paragraph argv element cannot
444
+ * survive cmd.exe when Bun.spawn resolves copilot to its Windows .cmd shim.
445
+ * Piping stdin keeps copilot non-interactive, so dropping -p costs nothing.
422
446
  */
423
- export function buildCopilotArgs(opts: InferenceOptions): string[] {
424
- const parts: string[] = [];
425
- if (opts.system) parts.push(opts.system);
426
- parts.push(opts.user);
427
- if (opts.jsonSchema) {
428
- parts.push(
429
- `Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${JSON.stringify(opts.jsonSchema)}`
430
- );
431
- }
432
- const prompt = parts.join("\n\n");
447
+ export function buildCopilotArgs(_opts: InferenceOptions): string[] {
433
448
  return [
434
- "-p",
435
- prompt,
436
449
  "--no-custom-instructions",
437
450
  "--disable-builtin-mcps",
438
451
  "--no-auto-update",
@@ -465,12 +478,40 @@ export function extractOpencodeText(rawStdout: string): string {
465
478
  return texts.join("").trim();
466
479
  }
467
480
 
481
+ /**
482
+ * Render a JSON schema for a prompt without double quotes.
483
+ *
484
+ * Bun.spawn resolves an agent CLI on Windows to its `.cmd` shim, which npm
485
+ * installs it as, and cmd.exe re-parses the command line it is handed. A double
486
+ * quote inside an argv element does not survive that round trip: the child exits
487
+ * non-zero having written nothing, so the dispatcher sees an empty abort and
488
+ * gives up. Single quotes carry the same shape to a model and are inert to
489
+ * cmd.exe, so the schema travels intact on every platform.
490
+ */
491
+ function schemaForPrompt(schema: Record<string, unknown>): string {
492
+ return JSON.stringify(schema).replaceAll('"', "'");
493
+ }
494
+
495
+ /** The one instruction line that asks a CLI agent for schema-shaped JSON. */
496
+ export function schemaInstruction(schema: Record<string, unknown>): string {
497
+ return `Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${schemaForPrompt(schema)}`;
498
+ }
499
+
500
+ /** system + user + schema instruction, the single prompt a CLI agent receives. */
501
+ export function buildCliPrompt(opts: InferenceOptions): string {
502
+ const parts: string[] = [];
503
+ if (opts.system) parts.push(opts.system);
504
+ parts.push(opts.user);
505
+ if (opts.jsonSchema) parts.push(schemaInstruction(opts.jsonSchema));
506
+ return parts.join("\n\n");
507
+ }
508
+
468
509
  /** Append a JSON-schema instruction to the system prompt (PAI pattern). */
469
510
  export function injectJsonSchemaInstruction(
470
511
  systemPrompt: string,
471
512
  schema: Record<string, unknown>
472
513
  ): string {
473
- const schemaLine = `Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${JSON.stringify(schema)}`;
514
+ const schemaLine = schemaInstruction(schema);
474
515
  return systemPrompt ? `${systemPrompt}\n\n${schemaLine}` : schemaLine;
475
516
  }
476
517
 
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Machine identity — who this install is, and how a record's origin becomes a
3
+ * name at display time.
4
+ *
5
+ * Records store the id and never the label. Resolution happens on read, so
6
+ * renaming a machine is a one-file edit that no stored record notices, and two
7
+ * machines sharing a label is a display concern rather than a data collision.
8
+ *
9
+ * `machine.json` lives at the PAL_HOME root, outside every exported directory,
10
+ * because importing it would give two installs one id and silently break every
11
+ * origin-scoped read built on top of it.
12
+ */
13
+
14
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { platform as osPlatform } from "node:os";
16
+ import { resolve } from "node:path";
17
+ import { parse, stringify } from "./frontmatter";
18
+ import { palHome, paths } from "./paths";
19
+
20
+ export interface MachineIdentity {
21
+ id: string;
22
+ label: string;
23
+ os: string;
24
+ createdAt: string;
25
+ }
26
+
27
+ const SHORT_ID_LENGTH = 4;
28
+
29
+ export function machineFilePath(home: string = palHome()): string {
30
+ return resolve(home, "machine.json");
31
+ }
32
+
33
+ function machinesDir(): string {
34
+ const dir = resolve(paths.memory(), "machines");
35
+ mkdirSync(dir, { recursive: true });
36
+ return dir;
37
+ }
38
+
39
+ /** First segment of the uuid — enough to disambiguate two same-labelled machines. */
40
+ export function shortId(id: string): string {
41
+ return id.replaceAll("-", "").slice(0, SHORT_ID_LENGTH);
42
+ }
43
+
44
+ /**
45
+ * Neutral default label. Deliberately not derived from the hostname: a hostname
46
+ * routinely carries the owner's real name, and the label travels in every
47
+ * exported registry entry.
48
+ */
49
+ export function defaultLabel(id: string): string {
50
+ return `machine-${shortId(id)}`;
51
+ }
52
+
53
+ function newIdentity(): MachineIdentity {
54
+ const id = crypto.randomUUID();
55
+ return {
56
+ id,
57
+ label: defaultLabel(id),
58
+ os: osPlatform(),
59
+ createdAt: new Date().toISOString(),
60
+ };
61
+ }
62
+
63
+ function hasUsableId(value: unknown): value is Partial<MachineIdentity> & { id: string } {
64
+ const v = value as Partial<MachineIdentity> | null;
65
+ return typeof v?.id === "string" && v.id.length > 0;
66
+ }
67
+
68
+ /**
69
+ * Fill in whatever a stored identity is missing. Only the id is irreplaceable —
70
+ * discarding one orphans every record that referenced it — so a file carrying a
71
+ * usable id is repaired rather than regenerated.
72
+ */
73
+ function repair(stored: Partial<MachineIdentity> & { id: string }): MachineIdentity {
74
+ return {
75
+ id: stored.id,
76
+ label: stored.label?.trim() || defaultLabel(stored.id),
77
+ os: stored.os || osPlatform(),
78
+ createdAt: stored.createdAt || new Date().toISOString(),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * This install's identity, created on first call and stable afterwards. The id
84
+ * is never regenerated once the file exists — a changed id orphans every record
85
+ * that referenced the old one.
86
+ */
87
+ export function loadMachine(home: string = palHome()): MachineIdentity {
88
+ const file = machineFilePath(home);
89
+ if (existsSync(file)) {
90
+ try {
91
+ const parsed = JSON.parse(readFileSync(file, "utf-8")) as unknown;
92
+ if (hasUsableId(parsed)) return repair(parsed);
93
+ } catch {
94
+ /* fall through to regeneration below */
95
+ }
96
+ }
97
+ const identity = newIdentity();
98
+ mkdirSync(home, { recursive: true });
99
+ writeFileSync(file, `${JSON.stringify(identity, null, 2)}\n`);
100
+ return identity;
101
+ }
102
+
103
+ /** Rename this machine. No stored record is touched — labels resolve on read. */
104
+ export function setLabel(label: string, home: string = palHome()): MachineIdentity {
105
+ const current = loadMachine(home);
106
+ const updated = { ...current, label: label.trim() || current.label };
107
+ writeFileSync(machineFilePath(home), `${JSON.stringify(updated, null, 2)}\n`);
108
+ return updated;
109
+ }
110
+
111
+ export interface RegistryEntry {
112
+ id: string;
113
+ label: string;
114
+ os: string;
115
+ }
116
+
117
+ function registryPath(id: string): string {
118
+ return resolve(machinesDir(), `${id}.md`);
119
+ }
120
+
121
+ /** Write (or refresh) a machine's registry entry. Registry entries are exported. */
122
+ export function writeRegistryEntry(entry: RegistryEntry, body = ""): string {
123
+ const file = registryPath(entry.id);
124
+ const existingBody = existsSync(file) ? parse(readFileSync(file, "utf-8")).body : "";
125
+ const content = stringify(
126
+ {
127
+ id: entry.id,
128
+ label: entry.label,
129
+ os: entry.os,
130
+ updated: new Date().toISOString(),
131
+ },
132
+ body || existingBody
133
+ );
134
+ writeFileSync(file, content);
135
+ return file;
136
+ }
137
+
138
+ /** Every known machine, this one and any that arrived via import. */
139
+ export function readRegistry(): RegistryEntry[] {
140
+ const dir = machinesDir();
141
+ const entries: RegistryEntry[] = [];
142
+ for (const name of readdirSync(dir)) {
143
+ if (!name.endsWith(".md")) continue;
144
+ try {
145
+ const meta = parse<Record<string, string>>(
146
+ readFileSync(resolve(dir, name), "utf-8")
147
+ ).meta;
148
+ if (meta.id && meta.label) {
149
+ entries.push({ id: meta.id, label: meta.label, os: meta.os ?? "" });
150
+ }
151
+ } catch {
152
+ /* a malformed entry must not hide the rest of the registry */
153
+ }
154
+ }
155
+ return entries;
156
+ }
157
+
158
+ /**
159
+ * Name for a record's origin id. Unknown ids fall back to the short id so a
160
+ * record from a machine whose entry has not arrived yet still reads sensibly.
161
+ * A label shared by two machines is suffixed rather than deduplicated — the
162
+ * registry is not always reachable, so uniqueness can never be enforced.
163
+ */
164
+ export function displayName(id: string, registry: RegistryEntry[]): string {
165
+ const entry = registry.find((e) => e.id === id);
166
+ if (!entry) return shortId(id);
167
+ const sharesLabel = registry.some((e) => e.id !== id && e.label === entry.label);
168
+ return sharesLabel ? `${entry.label}·${shortId(id)}` : entry.label;
169
+ }
170
+
171
+ /** Register this install so its label can be resolved on any machine. */
172
+ export function ensureRegistered(home: string = palHome()): MachineIdentity {
173
+ const identity = loadMachine(home);
174
+ writeRegistryEntry({ id: identity.id, label: identity.label, os: identity.os });
175
+ return identity;
176
+ }