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 +1 -1
- package/assets/skills/consulting-report/tools/generate-pdf.ts +2 -2
- package/assets/statusline.ps1 +8 -1
- package/assets/statusline.sh +8 -1
- package/assets/templates/hooks.codex.json +10 -0
- package/assets/templates/hooks.copilot.json +7 -0
- package/assets/templates/hooks.cursor.json +6 -0
- package/assets/templates/settings.claude.json +11 -0
- package/package.json +1 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/setup-auto-update.ts +46 -0
- package/src/hooks/AutoUpdate.ts +16 -0
- package/src/hooks/LoadContext.ts +7 -0
- package/src/hooks/SessionClose.ts +25 -0
- package/src/hooks/handlers/agenda.ts +1 -1
- package/src/hooks/handlers/update-check.ts +15 -16
- package/src/hooks/lib/auto-update.ts +294 -0
- package/src/hooks/lib/goal-links.ts +2 -2
- package/src/hooks/lib/security.ts +1 -1
- package/src/hooks/lib/settings.ts +2 -0
- package/src/targets/opencode/plugin.ts +6 -0
- package/src/tools/control-room/server.ts +26 -0
- package/src/tools/control-room/ui/dist/assets/{index-BoQjFpzV.js → index-DyhnNmdi.js} +1 -1
- package/src/tools/control-room/ui/dist/index.html +1 -1
- package/src/tools/control-room/ui/lib/write.ts +9 -1
- package/src/tools/control-room/ui/screens/settings.tsx +103 -1
- package/src/tools/control-room/writes.ts +11 -0
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/
|
|
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/
|
|
33
|
+
/** Public path to client logo (e.g. "/logos/client.svg"). Used in the PDF header. */
|
|
34
34
|
clientLogoSrc?: string;
|
|
35
35
|
}
|
|
36
36
|
|
package/assets/statusline.ps1
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/assets/statusline.sh
CHANGED
|
@@ -212,7 +212,14 @@ if [ -f "$UPDATE_CACHE" ]; then
|
|
|
212
212
|
else
|
|
213
213
|
VERSION_STR="$UC_CURRENT (new commits)"
|
|
214
214
|
fi
|
|
215
|
-
|
|
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
|
|
|
@@ -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
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
|
+
}
|
package/src/hooks/LoadContext.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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 — "
|
|
5
|
-
* serves "
|
|
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\
|
|
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.
|