portable-agent-layer 0.85.7 → 0.85.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.85.7",
3
+ "version": "0.85.9",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The doctor's git and gh checks. Neither is required: without git, project
3
+ * matching by remote and the update checks skip; without gh, nothing does yet.
4
+ */
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import { findBinaryOnPath } from "../hooks/lib/which";
8
+
9
+ export interface DoctorLine {
10
+ level: "ok" | "warn" | "info";
11
+ text: string;
12
+ }
13
+
14
+ type LinuxFamily = "debian" | "fedora" | "arch" | "alpine" | "suse";
15
+
16
+ interface Host {
17
+ platform: NodeJS.Platform;
18
+ osRelease: string;
19
+ timeoutMs: number;
20
+ }
21
+
22
+ const GH_LINUX_DOCS = "https://github.com/cli/cli/blob/trunk/docs/install_linux.md";
23
+
24
+ function osReleaseIds(osRelease: string): string[] {
25
+ const field = (key: string) =>
26
+ osRelease.match(new RegExp(String.raw`^${key}="?([^"\n]*)"?$`, "m"))?.[1] ?? "";
27
+ return `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/).filter(Boolean);
28
+ }
29
+
30
+ function linuxFamily(osRelease: string): LinuxFamily | null {
31
+ const ids = osReleaseIds(osRelease);
32
+ const families: [LinuxFamily, string[]][] = [
33
+ ["debian", ["debian", "ubuntu"]],
34
+ ["fedora", ["fedora", "rhel", "centos"]],
35
+ ["arch", ["arch"]],
36
+ ["alpine", ["alpine"]],
37
+ ["suse", ["suse", "opensuse"]],
38
+ ];
39
+ return families.find(([, names]) => names.some((n) => ids.includes(n)))?.[0] ?? null;
40
+ }
41
+
42
+ const GIT_ON_LINUX: Record<LinuxFamily, string> = {
43
+ debian: "sudo apt install git",
44
+ fedora: "sudo dnf install git",
45
+ arch: "sudo pacman -S git",
46
+ alpine: "sudo apk add git",
47
+ suse: "sudo zypper install git",
48
+ };
49
+
50
+ const GH_ON_LINUX: Partial<Record<LinuxFamily, string>> = {
51
+ fedora: "sudo dnf install gh",
52
+ arch: "sudo pacman -S github-cli",
53
+ };
54
+
55
+ export function gitInstallHint(platform: NodeJS.Platform, osRelease: string): string {
56
+ if (platform === "win32") return "`winget install Git.Git`";
57
+ if (platform === "darwin") return "`brew install git` or `xcode-select --install`";
58
+ const family = linuxFamily(osRelease);
59
+ return family ? `\`${GIT_ON_LINUX[family]}\`` : "install git with your package manager";
60
+ }
61
+
62
+ export function ghInstallHint(platform: NodeJS.Platform, osRelease: string): string {
63
+ if (platform === "win32") return "`winget install GitHub.cli`";
64
+ if (platform === "darwin") return "`brew install gh`";
65
+ const family = linuxFamily(osRelease);
66
+ const command = family && GH_ON_LINUX[family];
67
+ return command ? `\`${command}\`` : `see ${GH_LINUX_DOCS}`;
68
+ }
69
+
70
+ function run(binary: string, args: string[], timeoutMs: number) {
71
+ const result = Bun.spawnSync([binary, ...args], {
72
+ stdout: "pipe",
73
+ stderr: "ignore",
74
+ timeout: timeoutMs,
75
+ windowsHide: true,
76
+ });
77
+ return {
78
+ answered: !result.exitedDueToTimeout,
79
+ ok: result.success,
80
+ firstLine: result.stdout.toString().trim().split("\n")[0] ?? "",
81
+ };
82
+ }
83
+
84
+ function installed(name: string, timeoutMs: number) {
85
+ const binary = findBinaryOnPath(name);
86
+ if (!binary) return null;
87
+ const version = run(binary, ["--version"], timeoutMs);
88
+ return version.ok ? { binary, version: version.firstLine } : null;
89
+ }
90
+
91
+ function gitLine(host: Host): DoctorLine {
92
+ const git = installed("git", host.timeoutMs);
93
+ if (git) return { level: "ok", text: git.version };
94
+ return {
95
+ level: "warn",
96
+ text: `git — not found; project matching by remote, the update check and auto-update skip without it. ${gitInstallHint(host.platform, host.osRelease)}`,
97
+ };
98
+ }
99
+
100
+ function ghLine(host: Host): DoctorLine {
101
+ const gh = installed("gh", host.timeoutMs);
102
+ if (!gh)
103
+ return {
104
+ level: "info",
105
+ text: `gh — not installed (optional; lets PAL see PR, CI and release state — ${ghInstallHint(host.platform, host.osRelease)})`,
106
+ };
107
+ const auth = run(gh.binary, ["auth", "status"], host.timeoutMs);
108
+ if (!auth.answered)
109
+ return { level: "info", text: `${gh.version} — could not check the login in time` };
110
+ if (!auth.ok)
111
+ return {
112
+ level: "warn",
113
+ text: `${gh.version} — not logged in; run \`gh auth login\``,
114
+ };
115
+ return { level: "ok", text: `${gh.version}, logged in` };
116
+ }
117
+
118
+ function readOsRelease(): string {
119
+ try {
120
+ return readFileSync("/etc/os-release", "utf-8");
121
+ } catch {
122
+ return "";
123
+ }
124
+ }
125
+
126
+ export function versionControlLines(host: Partial<Host> = {}): DoctorLine[] {
127
+ const resolved: Host = {
128
+ platform: host.platform ?? process.platform,
129
+ osRelease: host.osRelease ?? (process.platform === "linux" ? readOsRelease() : ""),
130
+ timeoutMs: host.timeoutMs ?? 5000,
131
+ };
132
+ return [gitLine(resolved), ghLine(resolved)];
133
+ }
package/src/cli/index.ts CHANGED
@@ -56,6 +56,7 @@ import { telosStatus } from "../hooks/lib/telos-topics";
56
56
  import { findBinaryOnPath } from "../hooks/lib/which";
57
57
  import { log } from "../targets/lib";
58
58
  import { builtinToolVerbs, runBuiltinTool } from "./builtin-tools";
59
+ import { versionControlLines } from "./doctor-tools";
59
60
  import { checkPendingMigrations } from "./migrate";
60
61
  import { findSessionAgent, NO_SESSION_AGENT_MESSAGE } from "./session-agent";
61
62
 
@@ -842,6 +843,8 @@ function doctor(silent = false): DoctorResult {
842
843
  : info(
843
844
  `rtk — not installed (optional; enables Bash output compression — ${rtkInstallHint()})`
844
845
  );
846
+ const print = { ok, warn, info };
847
+ for (const line of versionControlLines()) print[line.level](line.text);
845
848
 
846
849
  console.log("");
847
850
  log.info("PAL state");
@@ -12,7 +12,6 @@
12
12
 
13
13
  import {
14
14
  existsSync,
15
- mkdirSync,
16
15
  readdirSync,
17
16
  readFileSync,
18
17
  renameSync,
@@ -22,7 +21,7 @@ import {
22
21
  } from "node:fs";
23
22
  import { basename, dirname, resolve } from "node:path";
24
23
  import { readBindings } from "../hooks/lib/bindings";
25
- import { mergeJsonlLines } from "../hooks/lib/import-merge";
24
+ import { foldHistoryInto } from "../hooks/lib/parked-history";
26
25
  import { palHome, paths } from "../hooks/lib/paths";
27
26
  import {
28
27
  legacyJsonToProgress,
@@ -55,6 +54,8 @@ interface Migration {
55
54
  description: string;
56
55
  check(): { pending: boolean; detail?: string };
57
56
  run(dryRun?: boolean): MigrationResult;
57
+ /** Moves its sources rather than copying them, so there is nothing left to delete. */
58
+ movesSources?: true;
58
59
  }
59
60
 
60
61
  // ── v1-projects: JSON progress files → ISA.md ─────────────────────
@@ -588,15 +589,6 @@ function destinationFor(item: StrandedHistory): string {
588
589
  : resolve(paths.unboundHistory(), `${item.slug}.jsonl`);
589
590
  }
590
591
 
591
- /** Union both sides so a destination that already has history keeps it. */
592
- function foldHistoryInto(target: string, source: string): number {
593
- const local = existsSync(target) ? readFileSync(target, "utf-8") : "";
594
- const { text, added } = mergeJsonlLines(local, readFileSync(source, "utf-8"));
595
- mkdirSync(dirname(target), { recursive: true });
596
- writeFileSync(target, text, "utf-8");
597
- return added;
598
- }
599
-
600
592
  function discardEmptyFolder(dir: string): void {
601
593
  try {
602
594
  if (readdirSync(dir).length === 0) rmdirSync(dir);
@@ -608,6 +600,7 @@ function discardEmptyFolder(dir: string): void {
608
600
  const v6HistorySlugs: Migration = {
609
601
  id: "v6-history-slugs",
610
602
  description: "Re-file session history keyed on a cwd name onto its owning project",
603
+ movesSources: true,
611
604
 
612
605
  check() {
613
606
  const stranded = strandedHistory();
@@ -728,7 +721,7 @@ export function runMigrate(args: string[]): void {
728
721
  console.log(
729
722
  ` ${dryRun ? "Would migrate" : "Migrated"}: ${totalMigrated} | Skipped: ${totalSkipped}`
730
723
  );
731
- if (!dryRun && totalMigrated > 0) {
724
+ if (!dryRun && totalMigrated > 0 && pending.some((m) => !m.movesSources)) {
732
725
  console.log(" Source files preserved — delete manually once verified.");
733
726
  }
734
727
  console.log("");
@@ -20,6 +20,7 @@
20
20
  import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
21
21
  import { homedir } from "node:os";
22
22
  import { resolve } from "node:path";
23
+ import { adoptParkedHistory } from "./parked-history";
23
24
  import { palHome, toPath } from "./paths";
24
25
 
25
26
  /** Project name → absolute path on this machine. */
@@ -91,6 +92,7 @@ export function writeBindings(bindings: Bindings, home: string = palHome()): voi
91
92
  const sorted: Bindings = {};
92
93
  for (const key of Object.keys(bindings).sort()) sorted[key] = bindings[key];
93
94
  writeFileSync(bindingsFilePath(home), `${JSON.stringify(sorted, null, 2)}\n`);
95
+ adoptParkedHistory(sorted, home);
94
96
  }
95
97
 
96
98
  /** The absolute path this machine has for `project`, or null when unbound. */
@@ -0,0 +1,59 @@
1
+ /**
2
+ * History from a directory no project claimed is parked under the directory's
3
+ * name. Once exactly one project is bound to a directory of that name, the
4
+ * parked history is its own and moves in.
5
+ */
6
+
7
+ import {
8
+ existsSync,
9
+ mkdirSync,
10
+ readdirSync,
11
+ readFileSync,
12
+ unlinkSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { basename, dirname, resolve } from "node:path";
16
+ import { mergeJsonlLines } from "./import-merge";
17
+ import { logError } from "./log";
18
+
19
+ type Bindings = Record<string, string>;
20
+
21
+ /** Union both sides so a destination that already has history keeps it. */
22
+ export function foldHistoryInto(target: string, source: string): number {
23
+ const local = existsSync(target) ? readFileSync(target, "utf-8") : "";
24
+ const { text, added } = mergeJsonlLines(local, readFileSync(source, "utf-8"));
25
+ mkdirSync(dirname(target), { recursive: true });
26
+ writeFileSync(target, text, "utf-8");
27
+ return added;
28
+ }
29
+
30
+ /** The project bound to a directory named `slug`, when exactly one is. */
31
+ function projectBoundUnder(slug: string, bindings: Bindings): string | null {
32
+ const owners = Object.entries(bindings).filter(([, path]) => basename(path) === slug);
33
+ return owners.length === 1 ? owners[0][0] : null;
34
+ }
35
+
36
+ function parkedDir(home: string): string {
37
+ return resolve(home, "memory", "state", "unbound-history");
38
+ }
39
+
40
+ function recordedProjectDir(home: string, project: string): string | null {
41
+ const dir = resolve(home, "memory", "projects", project);
42
+ return existsSync(resolve(dir, "ISA.md")) ? dir : null;
43
+ }
44
+
45
+ export function adoptParkedHistory(bindings: Bindings, home: string): void {
46
+ const dir = parkedDir(home);
47
+ if (!existsSync(dir)) return;
48
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".jsonl"))) {
49
+ const owner = projectBoundUnder(file.slice(0, -".jsonl".length), bindings);
50
+ const projectDir = owner && recordedProjectDir(home, owner);
51
+ if (!projectDir) continue;
52
+ try {
53
+ foldHistoryInto(resolve(projectDir, "history.jsonl"), resolve(dir, file));
54
+ unlinkSync(resolve(dir, file));
55
+ } catch (err) {
56
+ logError("parked-history:adopt", err);
57
+ }
58
+ }
59
+ }
@@ -267,11 +267,6 @@ export function writeProject(p: ProjectProgress): void {
267
267
  const detected = detectRemote(p.path);
268
268
  if (detected) p.remote = detected;
269
269
  }
270
- // Only a path that exists here may be bound. Saving a record is not a claim
271
- // about this machine's disk — `path` may have arrived from an imported record
272
- // written elsewhere, and binding it would recreate the very leak bindings exist
273
- // to prevent. An explicit `writeBinding` from the user stays unguarded.
274
- if (p.path && existsSync(p.path)) writeBinding(p.name, p.path);
275
270
  const meta: Record<string, unknown> = {
276
271
  name: p.name,
277
272
  status: p.status,
@@ -290,6 +285,11 @@ export function writeProject(p: ProjectProgress): void {
290
285
  if (p.placed) meta.placed = p.placed;
291
286
  if (p.placed_by) meta.placed_by = p.placed_by;
292
287
  writeFileSync(ensureAndGetIsaFile(p.name), stringify(meta, buildBody(p)), "utf-8");
288
+ // Only a path that exists here may be bound. Saving a record is not a claim
289
+ // about this machine's disk — `path` may have arrived from an imported record
290
+ // written elsewhere, and binding it would recreate the very leak bindings exist
291
+ // to prevent. An explicit `writeBinding` from the user stays unguarded.
292
+ if (p.path && existsSync(p.path)) writeBinding(p.name, p.path);
293
293
  }
294
294
 
295
295
  export function deleteProject(name: string): boolean {