portable-agent-layer 0.76.1 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,7 +77,7 @@ pal cli status # check your setup
77
77
  | `pal cli init` | Scaffold PAL home directory and install hooks |
78
78
  | `pal cli install` | Register hooks/skills for targets |
79
79
  | `pal cli uninstall` | Remove hooks/skills for targets |
80
- | `pal cli update` | Update PAL (git pull or npm update) and reinstall hooks |
80
+ | `pal cli update` | Update PAL (git pull or npm update) and reinstall hooks. Install asks once whether to do this daily on its own, applied when a session closes so opening PAL is never slowed; the switch lives in the control room under Settings → Updates. On a repo install a daily run waits while the clone has uncommitted changes |
81
81
  | `pal cli export` | Export user state (telos, memory) to a zip |
82
82
  | `pal cli import` | Import user state from a zip |
83
83
  | `pal cli status` | Show current PAL configuration |
@@ -28,9 +28,9 @@ interface ReportMeta {
28
28
  reportTitle: string;
29
29
  classification: string;
30
30
  consultancyName: string;
31
- /** Public path to consultancy logo (e.g. "/logos/konvert7.svg"). Used in the PDF footer. */
31
+ /** Public path to consultancy logo (e.g. "/logos/consultancy.svg"). Used in the PDF footer. */
32
32
  consultancyLogoSrc?: string;
33
- /** Public path to client logo (e.g. "/logos/transcend.svg"). Used in the PDF header. */
33
+ /** Public path to client logo (e.g. "/logos/client.svg"). Used in the PDF header. */
34
34
  clientLogoSrc?: string;
35
35
  }
36
36
 
@@ -167,7 +167,14 @@ if (Test-Path $updateCache) {
167
167
  $uc = Get-Content $updateCache -Raw -ErrorAction SilentlyContinue | ConvertFrom-Json
168
168
  if ($uc.available -eq $true) {
169
169
  $versionStr = if ($uc.current -ne $uc.latest) { "$($uc.current) -> $($uc.latest)" } else { $uc.current + " (new commits)" }
170
- $UPDATE_LINE = "[update] $versionStr run: pal cli update"
170
+ # With daily updates on, PAL applies this when the session closes — so the
171
+ # next step is to restart, not to run the command yourself.
172
+ $palSettings = Join-Path $env:USERPROFILE ".pal\memory\pal-settings.json"
173
+ $autoUpdate = $false
174
+ if (Test-Path $palSettings) {
175
+ try { $autoUpdate = (Get-Content $palSettings -Raw | ConvertFrom-Json).autoUpdate.enabled -eq $true } catch {}
176
+ }
177
+ $UPDATE_LINE = if ($autoUpdate) { "[update] $versionStr restart to apply" } else { "[update] $versionStr run: pal cli update" }
171
178
  }
172
179
  } catch {}
173
180
  }
@@ -212,7 +212,14 @@ if [ -f "$UPDATE_CACHE" ]; then
212
212
  else
213
213
  VERSION_STR="$UC_CURRENT (new commits)"
214
214
  fi
215
- UPDATE_LINE="📦 update: $VERSION_STR run: pal cli update"
215
+ # With daily updates on, PAL applies this when the session closes — so the
216
+ # next step is to restart, not to run the command yourself.
217
+ AUTO_UPDATE=$(jq -r '.autoUpdate.enabled // false' "$HOME/.pal/memory/pal-settings.json" 2>/dev/null)
218
+ if [ "$AUTO_UPDATE" = "true" ]; then
219
+ UPDATE_LINE="📦 update: $VERSION_STR restart to apply"
220
+ else
221
+ UPDATE_LINE="📦 update: $VERSION_STR run: pal cli update"
222
+ fi
216
223
  fi
217
224
  fi
218
225
 
@@ -53,6 +53,16 @@
53
53
  }
54
54
  ]
55
55
  }
56
+ ],
57
+ "SessionEnd": [
58
+ {
59
+ "hooks": [
60
+ {
61
+ "type": "command",
62
+ "command": "bun run {{PKG_ROOT}}/src/hooks/SessionClose.ts --agent=codex"
63
+ }
64
+ ]
65
+ }
56
66
  ]
57
67
  }
58
68
  }
@@ -57,6 +57,13 @@
57
57
  "bash": "bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts --agent=copilot",
58
58
  "powershell": "bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts --agent=copilot"
59
59
  }
60
+ ],
61
+ "sessionEnd": [
62
+ {
63
+ "type": "command",
64
+ "bash": "bun run {{PKG_ROOT}}/src/hooks/SessionClose.ts --agent=copilot",
65
+ "powershell": "bun run {{PKG_ROOT}}/src/hooks/SessionClose.ts --agent=copilot"
66
+ }
60
67
  ]
61
68
  }
62
69
  }
@@ -54,6 +54,12 @@
54
54
  "type": "command",
55
55
  "command": "bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts --agent=cursor"
56
56
  }
57
+ ],
58
+ "sessionEnd": [
59
+ {
60
+ "type": "command",
61
+ "command": "bun run {{PKG_ROOT}}/src/hooks/SessionClose.ts --agent=cursor"
62
+ }
57
63
  ]
58
64
  }
59
65
  }
@@ -196,6 +196,17 @@
196
196
  }
197
197
  ]
198
198
  }
199
+ ],
200
+ "SessionEnd": [
201
+ {
202
+ "matcher": "prompt_input_exit|logout|other",
203
+ "hooks": [
204
+ {
205
+ "type": "command",
206
+ "command": "bun run {{PKG_ROOT}}/src/hooks/SessionClose.ts --agent=claude"
207
+ }
208
+ ]
209
+ }
199
210
  ]
200
211
  },
201
212
  "autoMemoryEnabled": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.76.1",
3
+ "version": "0.78.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -178,6 +178,9 @@ async function session(sessionArgs: string[]) {
178
178
  await checkForUpdate();
179
179
  const notice = getUpdateNotice();
180
180
  if (notice) console.log(`\n${notice}`);
181
+ const { autoUpdateOnClose } = await import("../hooks/lib/auto-update");
182
+ const closing = autoUpdateOnClose();
183
+ if (closing) console.log(`\n${closing}`);
181
184
  } catch {
182
185
  // Non-critical
183
186
  }
@@ -1245,10 +1248,12 @@ async function install(targets: Targets) {
1245
1248
  await import("../targets/lib");
1246
1249
  const { promptIdentity } = await import("./setup-identity");
1247
1250
  const { promptAttribution } = await import("./setup-attribution");
1251
+ const { promptAutoUpdate } = await import("./setup-auto-update");
1248
1252
  scaffoldTelos();
1249
1253
  scaffoldPalSettings();
1250
1254
  await promptIdentity();
1251
1255
  await promptAttribution();
1256
+ await promptAutoUpdate();
1252
1257
  pointAtOnboarding();
1253
1258
 
1254
1259
  // Registers the label loadActor derives, so it travels on the next export.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Daily unattended updates — one-time opt-in prompt.
3
+ *
4
+ * Asked once during `pal install`, the same seam git attribution uses, so a new
5
+ * user sees it at init and an existing one on their next update. The non-TTY
6
+ * guard is load-bearing twice over: it keeps CI silent, and it keeps the
7
+ * reinstall at the end of an unattended update from waiting on an answer nobody
8
+ * is there to give.
9
+ */
10
+
11
+ import * as clack from "@clack/prompts";
12
+ import { isRepoMode } from "../hooks/handlers/update-check";
13
+ import { raw as readSettings, write as writeSettings } from "../hooks/lib/settings";
14
+
15
+ /** Only a git clone can be mid-change; a global package install has no such state. */
16
+ function whatItDoes(): string {
17
+ const daily =
18
+ "Once a day, when you close a session, PAL updates itself in the background.\nOpening PAL is never slowed down, and the statusline says when to restart.";
19
+ return isRepoMode()
20
+ ? `${daily}\nIt waits while this clone has uncommitted changes.`
21
+ : daily;
22
+ }
23
+
24
+ export async function promptAutoUpdate(): Promise<void> {
25
+ if (!process.stdin.isTTY) return;
26
+
27
+ const settings = { ...readSettings() };
28
+ if (settings.autoUpdate?.decided) return;
29
+
30
+ clack.intro("Automatic updates");
31
+ clack.note(whatItDoes(), "Keep PAL up to date on its own?");
32
+
33
+ const enabled = await clack.confirm({
34
+ message: "Turn on daily automatic updates?",
35
+ initialValue: false,
36
+ });
37
+ if (clack.isCancel(enabled)) {
38
+ clack.cancel("Skipped — will ask again next time");
39
+ return;
40
+ }
41
+
42
+ settings.autoUpdate = { enabled: enabled === true, decided: true };
43
+ writeSettings(settings);
44
+ const state = enabled ? "Daily updates on" : "Daily updates off";
45
+ clack.outro(`${state} ✓ · change later: control room → Settings → Updates`);
46
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Spawned, never awaited — runs the daily self-update in a process of its own.
3
+ *
4
+ * LoadContext decides whether today's update is due; the control room's button
5
+ * starts this directly. Either way the work happens here so nothing waits on a
6
+ * git pull and a reinstall.
7
+ */
8
+
9
+ import { runAutoUpdate } from "./lib/auto-update";
10
+ import { logError } from "./lib/log";
11
+
12
+ try {
13
+ runAutoUpdate();
14
+ } catch (err) {
15
+ logError("AutoUpdate", err);
16
+ }
@@ -11,6 +11,7 @@
11
11
  import { mkdirSync, writeFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
13
  import { getActiveAgent } from "./lib/agent";
14
+ import { autoUpdateOnStart } from "./lib/auto-update";
14
15
  import { buildClaudeMd, regenerateIfNeeded } from "./lib/claude-md";
15
16
  import { type AgentTarget, buildSystemReminder } from "./lib/context";
16
17
  import { logContextSnapshot, logDebug, logError } from "./lib/log";
@@ -38,6 +39,12 @@ try {
38
39
  logError("LoadContext:regenerate", err);
39
40
  }
40
41
 
42
+ try {
43
+ if (autoUpdateOnStart()) logDebug("LoadContext", "close hook silent — updating now");
44
+ } catch (err) {
45
+ logError("LoadContext:auto-update", err);
46
+ }
47
+
41
48
  try {
42
49
  const active = getActiveAgent();
43
50
  // The reminder is built for one of three targets; every other agent reads the
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Hook: session end — the moment PAL updates itself.
3
+ *
4
+ * Every agent PAL targets fires one of these when a session is done: Claude's
5
+ * SessionEnd, Cursor's sessionEnd, Codex's SessionEnd, Copilot's sessionEnd.
6
+ * opencode has no session-end event and calls the same library function from its
7
+ * plugin when the server is disposed.
8
+ *
9
+ * Closing is the right moment because the work is over: the reinstall cannot
10
+ * rewrite the config of a session still in use, and opening PAL costs nothing.
11
+ */
12
+
13
+ import { autoUpdateOnClose } from "./lib/auto-update";
14
+ import { logError } from "./lib/log";
15
+ import { isPalSpawnedInference } from "./lib/spawn-guard";
16
+ import { readStdinJSON } from "./lib/stdin";
17
+
18
+ if (isPalSpawnedInference()) process.exit(0);
19
+
20
+ try {
21
+ const input = await readStdinJSON<{ reason?: string }>();
22
+ autoUpdateOnClose(input?.reason);
23
+ } catch (err) {
24
+ logError("SessionClose", err);
25
+ }
@@ -343,7 +343,7 @@ async function writeMoves(sessionId?: string): Promise<boolean> {
343
343
  "You write the first three lines a person reads in the morning.",
344
344
  "You are given their goals and a ranked list of their projects and goals with the reason each was ranked.",
345
345
  "Write exactly three moves, most consequential first.",
346
- "A move is a sentence naming an action, not a project name: 'Send ACE the mapping one-pager' beats 'work on ontology'.",
346
+ "A move is a sentence naming an action, not a project name: 'Send the supplier the revised quote' beats 'work on billing'.",
347
347
  "Prefer what is blocked on the person themselves, then what serves a goal, then what is merely urgent.",
348
348
  "Name the project each move belongs to, copying its slug exactly from the list you were given. A move that belongs to no project takes an empty string.",
349
349
  "Never invent a fact that is not in what you were given.",
@@ -12,7 +12,7 @@ import { resolve } from "node:path";
12
12
  import { logDebug } from "../lib/log";
13
13
  import { ensureDir, palPkg, paths } from "../lib/paths";
14
14
 
15
- interface UpdateCache {
15
+ export interface UpdateCache {
16
16
  checkedAt: string;
17
17
  available: boolean;
18
18
  current: string;
@@ -26,18 +26,24 @@ function cachePath(): string {
26
26
  return resolve(ensureDir(paths.state()), "update-available.json");
27
27
  }
28
28
 
29
- function readCache(): UpdateCache | null {
29
+ /** The last check's result whatever its age — the notice and the page read this, not the TTL. */
30
+ export function cachedStatus(): UpdateCache | null {
30
31
  try {
31
32
  const fp = cachePath();
32
33
  if (!existsSync(fp)) return null;
33
- const cache = JSON.parse(readFileSync(fp, "utf-8")) as UpdateCache;
34
- if (Date.now() - new Date(cache.checkedAt).getTime() < CACHE_TTL_MS) return cache;
35
- return null; // expired
34
+ return JSON.parse(readFileSync(fp, "utf-8")) as UpdateCache;
36
35
  } catch {
37
36
  return null;
38
37
  }
39
38
  }
40
39
 
40
+ function readCache(): UpdateCache | null {
41
+ const cache = cachedStatus();
42
+ if (!cache) return null;
43
+ const fresh = Date.now() - new Date(cache.checkedAt).getTime() < CACHE_TTL_MS;
44
+ return fresh ? cache : null;
45
+ }
46
+
41
47
  function writeCache(cache: UpdateCache): void {
42
48
  try {
43
49
  writeFileSync(cachePath(), JSON.stringify(cache, null, 2), "utf-8");
@@ -50,7 +56,7 @@ export function isRepoMode(): boolean {
50
56
  return existsSync(resolve(palPkg(), ".git"));
51
57
  }
52
58
 
53
- function getInstalledVersion(): string {
59
+ export function getInstalledVersion(): string {
54
60
  try {
55
61
  const pkg = JSON.parse(readFileSync(resolve(palPkg(), "package.json"), "utf-8"));
56
62
  return pkg.version || "0.0.0";
@@ -193,14 +199,7 @@ export function clearUpdateCache(): void {
193
199
 
194
200
  /** Read cached update status for greeting display. Returns null if no update. */
195
201
  export function getUpdateNotice(): string | null {
196
- try {
197
- const fp = cachePath();
198
- if (!existsSync(fp)) return null;
199
- const cache = JSON.parse(readFileSync(fp, "utf-8")) as UpdateCache;
200
- if (!cache.available) return null;
201
-
202
- return `📦 Update available: ${cache.current} → ${cache.latest} (pal cli update)`;
203
- } catch {
204
- return null;
205
- }
202
+ const cache = cachedStatus();
203
+ if (!cache?.available) return null;
204
+ return `📦 Update available: ${cache.current} → ${cache.latest} (pal cli update)`;
206
205
  }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The daily self-update: when it may run unattended, and what came of it.
3
+ *
4
+ * `pal cli update` still owns the pull and the reinstall — this module only
5
+ * decides whether to start it and records the outcome, so the command shape
6
+ * lives in exactly one place.
7
+ *
8
+ * The ledger in state/auto-update.json is deliberately not update-available.json:
9
+ * that one is an hourly detection cache, this one is the record of what was
10
+ * attempted. A skip is stamped separately from an attempt so that skipping today
11
+ * never consumes today's attempt.
12
+ */
13
+
14
+ import { spawn, spawnSync } from "node:child_process";
15
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { resolve } from "node:path";
17
+ import { cachedStatus, getInstalledVersion, isRepoMode } from "../handlers/update-check";
18
+ import { logDebug, logError } from "./log";
19
+ import { assets, palPkg, paths } from "./paths";
20
+ import { raw as rawSettings } from "./settings";
21
+
22
+ export interface AutoUpdateLedger {
23
+ attemptedAt?: string;
24
+ finishedAt?: string;
25
+ ok?: boolean;
26
+ from?: string;
27
+ to?: string;
28
+ error?: string;
29
+ skippedAt?: string;
30
+ skipped?: string;
31
+ }
32
+
33
+ export interface AutoUpdateStatus {
34
+ enabled: boolean;
35
+ decided: boolean;
36
+ current: string;
37
+ latest: string | null;
38
+ available: boolean;
39
+ checkedAt: string | null;
40
+ mode: "repo" | "package";
41
+ last: AutoUpdateLedger | null;
42
+ }
43
+
44
+ const DAY_MS = 24 * 60 * 60 * 1000;
45
+ const RESCUE_AFTER_MS = 3 * DAY_MS;
46
+ const LOCK_STALE_MS = 30 * 60 * 1000;
47
+ const DIRTY_TREE = "uncommitted changes in the PAL repo";
48
+
49
+ /**
50
+ * Ending a session to clear or resume it leaves the agent running, so an update
51
+ * there would rewrite the config of a CLI the user is still sitting in front of.
52
+ */
53
+ const KEEPS_THE_AGENT_RUNNING = ["clear", "resume"];
54
+
55
+ function ledgerPath(): string {
56
+ return resolve(paths.state(), "auto-update.json");
57
+ }
58
+
59
+ function logPath(): string {
60
+ return resolve(paths.state(), "auto-update.log");
61
+ }
62
+
63
+ function lockPath(): string {
64
+ return resolve(paths.state(), "auto-update.lock");
65
+ }
66
+
67
+ export function readLedger(): AutoUpdateLedger | null {
68
+ try {
69
+ const fp = ledgerPath();
70
+ if (!existsSync(fp)) return null;
71
+ return JSON.parse(readFileSync(fp, "utf-8")) as AutoUpdateLedger;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function writeLedger(entry: AutoUpdateLedger): AutoUpdateLedger {
78
+ try {
79
+ writeFileSync(ledgerPath(), `${JSON.stringify(entry, null, 2)}\n`, "utf-8");
80
+ } catch (err) {
81
+ logError("auto-update:ledger", err);
82
+ }
83
+ return entry;
84
+ }
85
+
86
+ function isAutoUpdateEnabled(): boolean {
87
+ return rawSettings().autoUpdate?.enabled === true;
88
+ }
89
+
90
+ export function autoUpdateStatus(): AutoUpdateStatus {
91
+ const settings = rawSettings().autoUpdate ?? {};
92
+ const cache = cachedStatus();
93
+ return {
94
+ enabled: settings.enabled === true,
95
+ decided: settings.decided === true,
96
+ current: cache?.current ?? getInstalledVersion(),
97
+ latest: cache?.latest ?? null,
98
+ available: cache?.available === true,
99
+ checkedAt: cache?.checkedAt ?? null,
100
+ mode: cache?.mode ?? (isRepoMode() ? "repo" : "package"),
101
+ last: readLedger(),
102
+ };
103
+ }
104
+
105
+ /**
106
+ * A pull cannot fast-forward over uncommitted work, so an unattended update on a
107
+ * dirty clone would fail every night. It waits instead, and says so.
108
+ */
109
+ function hasUncommittedChanges(): boolean {
110
+ if (!isRepoMode()) return false;
111
+ const status = spawnSync("git", ["status", "--porcelain"], {
112
+ cwd: palPkg(),
113
+ encoding: "utf-8",
114
+ windowsHide: true,
115
+ });
116
+ if (status.status !== 0) return false;
117
+ return (status.stdout ?? "").trim().length > 0;
118
+ }
119
+
120
+ function recordSkip(reason: string): void {
121
+ writeLedger({ ...readLedger(), skippedAt: new Date().toISOString(), skipped: reason });
122
+ logDebug("auto-update", `skipped: ${reason}`);
123
+ }
124
+
125
+ function attemptedWithin(ledger: AutoUpdateLedger | null, now: number): boolean {
126
+ if (!ledger?.attemptedAt) return false;
127
+ return now - new Date(ledger.attemptedAt).getTime() < DAY_MS;
128
+ }
129
+
130
+ /** The unattended gate: opted in, not already tried today, and safe to pull. */
131
+ export function shouldAutoUpdate(now: number = Date.now()): boolean {
132
+ if (!isAutoUpdateEnabled()) return false;
133
+ if (attemptedWithin(readLedger(), now)) return false;
134
+ if (hasUncommittedChanges()) {
135
+ recordSkip(DIRTY_TREE);
136
+ return false;
137
+ }
138
+ return true;
139
+ }
140
+
141
+ function updateCommand(): string[] {
142
+ return [resolve(palPkg(), "src", "cli", "index.ts"), "cli", "update"];
143
+ }
144
+
145
+ function heldRecently(): boolean {
146
+ try {
147
+ const held = JSON.parse(readFileSync(lockPath(), "utf-8")) as { at?: string };
148
+ return (
149
+ Boolean(held.at) && Date.now() - new Date(String(held.at)).getTime() < LOCK_STALE_MS
150
+ );
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Two agents can close at the same moment, and two `git pull`s into one clone is
158
+ * not a race worth having. An exclusive create is the whole mechanism; a lock
159
+ * older than the longest plausible update is treated as abandoned.
160
+ */
161
+ function takeLock(): boolean {
162
+ const note = JSON.stringify({ pid: process.pid, at: new Date().toISOString() });
163
+ try {
164
+ writeFileSync(lockPath(), note, { flag: "wx" });
165
+ return true;
166
+ } catch {
167
+ if (heldRecently()) return false;
168
+ writeFileSync(lockPath(), note, "utf-8");
169
+ return true;
170
+ }
171
+ }
172
+
173
+ function releaseLock(): void {
174
+ try {
175
+ rmSync(lockPath(), { force: true });
176
+ } catch (err) {
177
+ logError("auto-update:lock", err);
178
+ }
179
+ }
180
+
181
+ export function endsTheSession(reason: string | undefined): boolean {
182
+ return !reason || !KEEPS_THE_AGENT_RUNNING.includes(reason);
183
+ }
184
+
185
+ function updatingNotice(): string {
186
+ const cache = cachedStatus();
187
+ const versions =
188
+ cache?.latest && cache.latest !== cache.current
189
+ ? ` (${cache.current} → ${cache.latest})`
190
+ : "";
191
+ return `📦 Updating PAL in the background${versions} — nothing to wait for, it finishes on its own.`;
192
+ }
193
+
194
+ /**
195
+ * What every agent's close hook calls. Returns the line a terminal should print,
196
+ * or null when there is nothing a user would want to know. Comparing the skip
197
+ * stamp across the gate is how a skip recorded just now is told apart from one
198
+ * left over from yesterday.
199
+ */
200
+ export function autoUpdateOnClose(reason?: string): string | null {
201
+ if (!endsTheSession(reason)) return null;
202
+ const skippedBefore = readLedger()?.skippedAt;
203
+ if (!shouldAutoUpdate()) {
204
+ const ledger = readLedger();
205
+ const skippedNow = ledger?.skippedAt && ledger.skippedAt !== skippedBefore;
206
+ return skippedNow ? `📦 PAL update waiting — ${ledger?.skipped}.` : null;
207
+ }
208
+ spawnAutoUpdate();
209
+ return updatingNotice();
210
+ }
211
+
212
+ function closeHookIsNotDelivering(ledger: AutoUpdateLedger | null): boolean {
213
+ if (!ledger?.attemptedAt) return true;
214
+ return Date.now() - new Date(ledger.attemptedAt).getTime() > RESCUE_AFTER_MS;
215
+ }
216
+
217
+ /**
218
+ * The second chance, not the usual path. A session that is killed, or an agent
219
+ * whose close event never arrives, would otherwise leave an install pinned
220
+ * forever on a statusline telling it to restart — and restarting would not help.
221
+ * Two small file reads on a normal start; the rest is only reached when the
222
+ * close hook has been silent for days.
223
+ */
224
+ export function autoUpdateOnStart(): boolean {
225
+ if (!isAutoUpdateEnabled()) return false;
226
+ if (!closeHookIsNotDelivering(readLedger())) return false;
227
+ if (!shouldAutoUpdate()) return false;
228
+ spawnAutoUpdate();
229
+ return true;
230
+ }
231
+
232
+ /**
233
+ * Runs in the detached child, never in the hook that spawned it. The attempt is
234
+ * stamped before the command starts so a run that dies mid-flight still counts
235
+ * against today — a broken update retries tomorrow, not every session.
236
+ */
237
+ export function runAutoUpdate(): AutoUpdateLedger {
238
+ if (hasUncommittedChanges()) {
239
+ recordSkip(DIRTY_TREE);
240
+ return readLedger() ?? {};
241
+ }
242
+ if (!takeLock()) {
243
+ logDebug("auto-update", "another update holds the lock");
244
+ return readLedger() ?? {};
245
+ }
246
+ try {
247
+ return update();
248
+ } finally {
249
+ releaseLock();
250
+ }
251
+ }
252
+
253
+ function update(): AutoUpdateLedger {
254
+ const from = getInstalledVersion();
255
+ writeLedger({ attemptedAt: new Date().toISOString(), from });
256
+
257
+ const run = spawnSync("bun", updateCommand(), {
258
+ cwd: palPkg(),
259
+ encoding: "utf-8",
260
+ windowsHide: true,
261
+ });
262
+ const output = `${run.stdout ?? ""}${run.stderr ?? ""}`;
263
+ try {
264
+ writeFileSync(logPath(), output, "utf-8");
265
+ } catch (err) {
266
+ logError("auto-update:log", err);
267
+ }
268
+
269
+ const ok = run.status === 0;
270
+ logDebug("auto-update", `update exited ${run.status}`);
271
+ return writeLedger({
272
+ ...readLedger(),
273
+ finishedAt: new Date().toISOString(),
274
+ ok,
275
+ from,
276
+ to: getInstalledVersion(),
277
+ error: ok ? undefined : (run.error?.message ?? `exit ${run.status}`),
278
+ });
279
+ }
280
+
281
+ /** Hands the update to its own process, which outlives the session that closed. */
282
+ export function spawnAutoUpdate(): void {
283
+ try {
284
+ const child = spawn("bun", [resolve(assets.hooks(), "AutoUpdate.ts")], {
285
+ detached: true,
286
+ stdio: "ignore",
287
+ windowsHide: true,
288
+ });
289
+ child.unref();
290
+ logDebug("auto-update", "detached update spawned");
291
+ } catch (err) {
292
+ logError("auto-update:spawn", err);
293
+ }
294
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Which projects serve which stated goal, and how far along that makes it.
3
3
  *
4
- * The linkage needs judgement — "Catalyst is the starter that pays for the rest"
5
- * serves "land two retained clients" only if you know what both mean — so a model
4
+ * The linkage needs judgement — "the starter kit is what pays for the rest"
5
+ * serves "reach steady revenue" only if you know what both mean — so a model
6
6
  * draws it. The progress does not: it is criteria closed over criteria written,
7
7
  * counted here, because a model returning "64%" says something unfalsifiable
8
8
  * about a goal it cannot measure.
@@ -14,7 +14,7 @@ const WIN_RECURSE_FLAG = String.raw`(?:-(?:r(?:e(?:c(?:u(?:r(?:se?)?)?)?)?)?f?|f
14
14
 
15
15
  /**
16
16
  * A whole root, not a directory inside one. The trailing lookahead is the part
17
- * that matters: without it `C:\` prefix-matches `C:\Users\rico\dist` and every
17
+ * that matters: without it `C:\` prefix-matches `C:\Users\user\dist` and every
18
18
  * ordinary recursive delete on Windows gets blocked.
19
19
  */
20
20
  const WIN_ROOT_TARGET = String.raw`["']?(?:[a-z]:[\\/]?\*?|\\\\|~|\$home|\$env:userprofile|\$env:systemdrive)["']?(?=["'\s;,)]|$)`;
@@ -25,6 +25,8 @@ export interface PalSettingsData {
25
25
  dynamicContext?: Record<string, boolean>;
26
26
  /** Git co-author attribution opt-in. `decided` gates the one-time prompt. */
27
27
  attribution?: { enabled?: boolean; decided?: boolean };
28
+ /** Daily unattended self-update opt-in. `decided` gates the one-time prompt. */
29
+ autoUpdate?: { enabled?: boolean; decided?: boolean };
28
30
  /**
29
31
  * Action-ledger user extension. `redactPaths` adds to the built-in set of
30
32
  * paths whose contents are never stored; it cannot shrink it.