Tier 1 (SessionStart-hook) kjører deteksjon kun når en Claude-sesjon starter.
Tier 2 installerer en lokal launchd LaunchAgent som fyrer SAMME Claude-frie
entrypoint daglig (kl. 03:00), uavhengig av sesjoner = ekte bakgrunn mellom
sesjoner. ToS-trygt: entrypointet kan strukturelt ikke invokere claude.
Søk-først-verifisert (2026-06): launchd > cron (Apple-anbefalt; cron krever
Full Disk Access); StartCalendarInterval fanger opp etter dvale; launchd har
minimalt PATH + ekspanderer ikke ~; process.execPath = Cellar-sti som dør ved
`brew upgrade node` → plistens PATH inkluderer /usr/local/bin.
Levert (TDD, test FØR kode):
- lib/launchd-plist.mjs (REN, SKRIVER ALDRI): renderPlist (XML-escape,
StartCalendarInterval, PATH-fix), defaultLabel, resolvePaths.
- scheduler.mjs (TYNN CLI): install|uninstall|status|run-now|print|run.
Strukturelt Claude-fri (kun process.execPath på run-detection.mjs +
launchctl; kilde-grep-testet). run gater på os_scheduler_cadence →
delegerer til run-detection.mjs. uid-0-nekt; idempotent bootout.
- detection-schedule.mjs (additiv, Tier-1 urørt): os_scheduler_cadence
('daily' default | 'interval') + daysSinceLastPoll ren helper.
- commands/kb-update.md: Tier-1/Tier-2-doc + caveats (uninstall=pause;
re-install etter node-upgrade).
Operatør-valg: daglig default, kadens egen innstilling (settes i onboarding/C2).
Verifisert: launchd-plist 13/13 · detection-schedule 21/21 · kb-update 173 ·
kb-eval 100 · validate 239/0 · test-hooks 6/6 · plutil -lint OK · live
install→status→idempotent→uninstall på maskinen (gui/501, rent).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
106 lines
4.1 KiB
JavaScript
106 lines
4.1 KiB
JavaScript
// launchd-plist.mjs — Spor C / C1 Tier 2: pure renderer for the detection
|
|
// LaunchAgent. SKRIVER ALDRI / never writes — it only builds strings and
|
|
// resolves paths. The thin imperative side (write the file, run launchctl)
|
|
// lives in scheduler.mjs, mirroring the detection-schedule.mjs (pure core) +
|
|
// run-detection.mjs (thin executor) split.
|
|
//
|
|
// Why these specific plist choices (search-first verified, 2026-06):
|
|
// - StartCalendarInterval (NOT StartInterval): a calendar trigger runs the
|
|
// job on wake if the fire time was missed during sleep; StartInterval just
|
|
// skips it. Decisive for a laptop that sleeps.
|
|
// - EnvironmentVariables.PATH: launchd starts jobs with a MINIMAL PATH, so a
|
|
// bare `node` lookup (run-detection.mjs uses execFileSync('node', ...)) would
|
|
// fail. We inject dirname(nodePath) PLUS the upgrade-stable /usr/local/bin
|
|
// symlink dir, so a `brew upgrade node` (which moves the version-pinned
|
|
// Cellar path) can't orphan the agent.
|
|
// - Absolute paths only: launchd does not expand `~`.
|
|
//
|
|
// Pure: imports only node:path/node:url. No fs, no child_process.
|
|
|
|
import { dirname } from 'node:path';
|
|
|
|
const LABEL = 'com.ktg.ms-ai-architect.detection';
|
|
|
|
/** The system PATH floor launchd should always have, plus Homebrew's stable symlink dir. */
|
|
const STABLE_PATH = ['/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
|
|
|
|
/** Escape a value for inclusion in a plist <string> (a plist is XML). */
|
|
export function xmlEscape(value) {
|
|
return String(value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
/** The stable reverse-DNS label for the detection LaunchAgent. */
|
|
export function defaultLabel() {
|
|
return LABEL;
|
|
}
|
|
|
|
/**
|
|
* Render a LaunchAgent plist that runs `<nodePath> <scriptPath> <...args>`
|
|
* daily at hour:minute. Every interpolated value is XML-escaped; the schedule
|
|
* is StartCalendarInterval (sleep-safe); RunAtLoad is false (first run at the
|
|
* next scheduled fire, not at install).
|
|
* @param {{label:string,nodePath:string,scriptPath:string,args:string[],hour:number,minute:number,logPath:string,workingDir:string}} o
|
|
* @returns {string}
|
|
*/
|
|
export function renderPlist({ label, nodePath, scriptPath, args = [], hour, minute, logPath, workingDir }) {
|
|
// PATH: dirname(node) first (so child `node` spawns resolve), then the stable floor.
|
|
const pathValue = [dirname(nodePath), ...STABLE_PATH].join(':');
|
|
const programArgs = [nodePath, scriptPath, ...args]
|
|
.map((a) => ` <string>${xmlEscape(a)}</string>`)
|
|
.join('\n');
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Label</key>
|
|
<string>${xmlEscape(label)}</string>
|
|
<key>ProgramArguments</key>
|
|
<array>
|
|
${programArgs}
|
|
</array>
|
|
<key>StartCalendarInterval</key>
|
|
<dict>
|
|
<key>Hour</key>
|
|
<integer>${Number(hour)}</integer>
|
|
<key>Minute</key>
|
|
<integer>${Number(minute)}</integer>
|
|
</dict>
|
|
<key>RunAtLoad</key>
|
|
<false/>
|
|
<key>EnvironmentVariables</key>
|
|
<dict>
|
|
<key>PATH</key>
|
|
<string>${xmlEscape(pathValue)}</string>
|
|
</dict>
|
|
<key>WorkingDirectory</key>
|
|
<string>${xmlEscape(workingDir)}</string>
|
|
<key>StandardOutPath</key>
|
|
<string>${xmlEscape(logPath)}</string>
|
|
<key>StandardErrorPath</key>
|
|
<string>${xmlEscape(logPath)}</string>
|
|
</dict>
|
|
</plist>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Resolve the absolute paths the installer needs. All absolute (no `~`).
|
|
* The LaunchAgent lives in ~/Library/LaunchAgents; the log lives in the
|
|
* gitignored scripts/kb-update/data/ dir; launchd runs scheduler.mjs (which
|
|
* gates on cadence, then delegates to the Claude-free run-detection.mjs).
|
|
* @param {{pluginRoot:string,homeDir:string,nodePath:string}} o
|
|
*/
|
|
export function resolvePaths({ pluginRoot, homeDir }) {
|
|
const label = LABEL;
|
|
return {
|
|
label,
|
|
agentPlistPath: `${homeDir}/Library/LaunchAgents/${label}.plist`,
|
|
scriptPath: `${pluginRoot}/scripts/kb-update/scheduler.mjs`,
|
|
logPath: `${pluginRoot}/scripts/kb-update/data/detection-scheduler.log`,
|
|
workingDir: pluginRoot,
|
|
};
|
|
}
|