portable-agent-layer 0.62.2 → 0.63.1

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.
@@ -87,14 +87,6 @@ if (Test-Path $projectsDir) {
87
87
  } catch { $OPEN_ISCS = 0 }
88
88
  }
89
89
 
90
- # Rate limits (Pro/Max only - absent for other plans)
91
- $FIVE_H_RAW = $data.rate_limits.five_hour.used_percentage
92
- $SEVEN_D_RAW = $data.rate_limits.seven_day.used_percentage
93
- $RATE_PARTS = @()
94
- if ($FIVE_H_RAW -ne $null) { $RATE_PARTS += "5h: $([int]$FIVE_H_RAW)%" }
95
- if ($SEVEN_D_RAW -ne $null) { $RATE_PARTS += "7d: $([int]$SEVEN_D_RAW)%" }
96
- $RATE_STR = if ($RATE_PARTS.Count -gt 0) { " - " + ($RATE_PARTS -join " | ") } else { "" }
97
-
98
90
  # Create context progress bar - if both are 0, data not yet available (pre-first API call)
99
91
  $NO_DATA = ($USED_RAW -eq $null -and $REM_RAW -eq $null)
100
92
  if ($NO_DATA) { $USED = 0; $REM = 100 }
@@ -115,6 +107,37 @@ $RESET = $ESC + "[0m"
115
107
  # Choose bar color based on context usage
116
108
  $BAR_COLOR = if ($USED -gt 80) { $RED } elseif ($USED -gt 60) { $YELLOW } else { $GREEN }
117
109
 
110
+ # Rate limits (Pro/Max only - absent for other plans)
111
+ $FIVE_H_RAW = $data.rate_limits.five_hour.used_percentage
112
+ $SEVEN_D_RAW = $data.rate_limits.seven_day.used_percentage
113
+
114
+ # Daily soft limit - spending the weekly budget evenly is 100/7 ~ 14% per day.
115
+ # Shows what is still sustainable per day for the rest of the window: remaining
116
+ # budget divided by days left. Dropping under the even-pace ideal means future
117
+ # days have already been eaten into. Both inputs are server-reported for the
118
+ # whole account, so every machine on the account computes the same figure.
119
+ # resets_at is Unix epoch seconds.
120
+ $DAY_STR = ""
121
+ $RESETS_AT = $data.rate_limits.seven_day.resets_at
122
+ if (($SEVEN_D_RAW -ne $null) -and ($RESETS_AT -ne $null)) {
123
+ $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
124
+ $daysLeft = ([double]$RESETS_AT - $now) / 86400
125
+ if ($daysLeft -gt 0) {
126
+ $dayIdeal = 100 / 7
127
+ $dayRate = (100 - [double]$SEVEN_D_RAW) / $daysLeft
128
+ if ($dayRate -lt 0) { $dayRate = 0 }
129
+ $DAY_COLOR = if ($dayRate -lt $dayIdeal) { $RED } elseif ($dayRate -lt ($dayIdeal * 1.1)) { $YELLOW } else { $GREEN }
130
+ $DAY_FLAG = if ($dayRate -lt $dayIdeal) { " !" } else { "" }
131
+ $DAY_STR = $DAY_COLOR + "day: $([int][math]::Round($dayRate))/$([int][math]::Round($dayIdeal))%$DAY_FLAG" + $RESET + $DIM
132
+ }
133
+ }
134
+
135
+ $RATE_PARTS = @()
136
+ if ($FIVE_H_RAW -ne $null) { $RATE_PARTS += "5h: $([int]$FIVE_H_RAW)%" }
137
+ if ($SEVEN_D_RAW -ne $null) { $RATE_PARTS += "7d: $([int]$SEVEN_D_RAW)%" }
138
+ if ($DAY_STR) { $RATE_PARTS += $DAY_STR }
139
+ $RATE_STR = if ($RATE_PARTS.Count -gt 0) { " - " + ($RATE_PARTS -join " | ") } else { "" }
140
+
118
141
  # PAL: Signal trend (reads 10-min cache written by session intelligence)
119
142
  $SIGNAL_STR = ""
120
143
  $signalCache = Join-Path $env:USERPROFILE ".pal\memory\state\signal-cache.json"
@@ -107,17 +107,6 @@ if [ -d "$PROJECTS_DIR" ]; then
107
107
  done
108
108
  fi
109
109
 
110
- # Rate limits (Pro/Max only — absent for other plans and on Cursor)
111
- FIVE_H=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty')
112
- SEVEN_D=$(echo "$input" | jq -r '.rate_limits.seven_day.used_percentage // empty')
113
- RATE_PARTS=()
114
- [ -n "$FIVE_H" ] && RATE_PARTS+=("5h: ${FIVE_H%.*}%")
115
- [ -n "$SEVEN_D" ] && RATE_PARTS+=("7d: ${SEVEN_D%.*}%")
116
- RATE_STR=""
117
- if [ ${#RATE_PARTS[@]} -gt 0 ]; then
118
- RATE_STR=" │ $(IFS=" | "; echo "${RATE_PARTS[*]}")"
119
- fi
120
-
121
110
  # Create context progress bar (20 chars wide)
122
111
  FILLED=$((USED / 5))
123
112
  EMPTY=$((20 - FILLED))
@@ -147,6 +136,51 @@ else
147
136
  CONTEXT_COLOR=$GREEN
148
137
  fi
149
138
 
139
+ # Rate limits (Pro/Max only — absent for other plans and on Cursor)
140
+ FIVE_H=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty')
141
+ SEVEN_D=$(echo "$input" | jq -r '.rate_limits.seven_day.used_percentage // empty')
142
+
143
+ # Daily soft limit — spending the weekly budget evenly is 100/7 ≈ 14% per day.
144
+ # Shows what is still sustainable per day for the rest of the window: remaining
145
+ # budget divided by days left. Dropping under the even-pace ideal means future
146
+ # days have already been eaten into. Both inputs are server-reported for the
147
+ # whole account, so every machine on the account computes the same figure.
148
+ # resets_at is Unix epoch seconds.
149
+ DAY_STR=""
150
+ RESETS_AT=$(echo "$input" | jq -r '.rate_limits.seven_day.resets_at // empty')
151
+ if [ -n "$SEVEN_D" ] && [ -n "$RESETS_AT" ]; then
152
+ DAYS_LEFT=$(echo "($RESETS_AT - $(date +%s)) / 86400" | bc -l)
153
+ if (( $(echo "$DAYS_LEFT > 0" | bc -l) )); then
154
+ DAY_IDEAL=$(echo "100 / 7" | bc -l)
155
+ DAY_RATE=$(echo "(100 - $SEVEN_D) / $DAYS_LEFT" | bc -l)
156
+ (( $(echo "$DAY_RATE < 0" | bc -l) )) && DAY_RATE=0
157
+ if (( $(echo "$DAY_RATE < $DAY_IDEAL" | bc -l) )); then
158
+ DAY_COLOR=$RED
159
+ DAY_FLAG=" ⚠️"
160
+ elif (( $(echo "$DAY_RATE < $DAY_IDEAL * 1.1" | bc -l) )); then
161
+ DAY_COLOR=$YELLOW
162
+ DAY_FLAG=""
163
+ else
164
+ DAY_COLOR=$GREEN
165
+ DAY_FLAG=""
166
+ fi
167
+ DAY_STR="${DAY_COLOR}day: $(printf '%.0f' "$DAY_RATE")/$(printf '%.0f' "$DAY_IDEAL")%${DAY_FLAG}${RESET}${DIM}"
168
+ fi
169
+ fi
170
+
171
+ RATE_PARTS=()
172
+ [ -n "$FIVE_H" ] && RATE_PARTS+=("5h: ${FIVE_H%.*}%")
173
+ [ -n "$SEVEN_D" ] && RATE_PARTS+=("7d: ${SEVEN_D%.*}%")
174
+ [ -n "$DAY_STR" ] && RATE_PARTS+=("$DAY_STR")
175
+ RATE_STR=""
176
+ if [ ${#RATE_PARTS[@]} -gt 0 ]; then
177
+ RATE_JOINED="${RATE_PARTS[0]}"
178
+ for PART in "${RATE_PARTS[@]:1}"; do
179
+ RATE_JOINED="${RATE_JOINED} | ${PART}"
180
+ done
181
+ RATE_STR=" │ ${RATE_JOINED}"
182
+ fi
183
+
150
184
  # PAL: Signal trend (reads 10-min cache written by session intelligence)
151
185
  SIGNAL_STR=""
152
186
  SIGNAL_CACHE="$HOME/.pal/memory/state/signal-cache.json"
@@ -4,33 +4,39 @@
4
4
  "sessionStart": [
5
5
  {
6
6
  "type": "command",
7
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts"
7
+ "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts",
8
+ "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts"
8
9
  }
9
10
  ],
10
11
  "userPromptSubmitted": [
11
12
  {
12
13
  "type": "command",
13
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts"
14
+ "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts",
15
+ "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts"
14
16
  }
15
17
  ],
16
18
  "preToolUse": [
17
19
  {
18
20
  "type": "command",
19
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts"
21
+ "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts",
22
+ "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts"
20
23
  },
21
24
  {
22
25
  "type": "command",
23
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts"
26
+ "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts",
27
+ "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts"
24
28
  },
25
29
  {
26
30
  "type": "command",
27
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
31
+ "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts",
32
+ "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
28
33
  }
29
34
  ],
30
35
  "agentStop": [
31
36
  {
32
37
  "type": "command",
33
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts"
38
+ "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts",
39
+ "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts"
34
40
  }
35
41
  ]
36
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.62.2",
3
+ "version": "0.63.1",
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
@@ -80,6 +80,20 @@ function checkTool(cmd: string, versionArgs: string[] = ["--version"]): ToolChec
80
80
  return { name: cmd, available: false };
81
81
  }
82
82
 
83
+ /**
84
+ * Copilot ships as a VS Code extension as well as a CLI, and the extension puts
85
+ * no `copilot` binary on PATH — but both read hooks, skills and agents out of
86
+ * ~/.copilot. Fall back to that directory so PAL's copilot checks still run.
87
+ */
88
+ function checkCopilot(): ToolCheck {
89
+ const cli = checkTool("copilot", ["version"]);
90
+ if (cli.available) return cli;
91
+ if (existsSync(platform.copilotDir())) {
92
+ return { name: "copilot", available: true, version: "~/.copilot (no CLI on PATH)" };
93
+ }
94
+ return cli;
95
+ }
96
+
83
97
  function detectAgent(): string | null {
84
98
  if (checkTool("claude").available) return "claude";
85
99
  if (checkTool("opencode").available) return "opencode";
@@ -443,13 +457,18 @@ function checkCopilotInstructionsPresent(): boolean {
443
457
 
444
458
  // ── Install integrity (Tier 2 doctor checks) ──
445
459
 
446
- /** Recursively collect every `command` or `bash` field value in a hook-config JSON. */
460
+ /** Hook-config keys that carry a shell command: cross-platform and per-shell variants. */
461
+ function isHookCommandField(key: string): boolean {
462
+ return key === "command" || key === "bash" || key === "powershell";
463
+ }
464
+
465
+ /** Recursively collect every command-carrying field value in a hook-config JSON. */
447
466
  function extractAllHookCommands(obj: unknown, out: string[] = []): string[] {
448
467
  if (Array.isArray(obj)) {
449
468
  for (const item of obj) extractAllHookCommands(item, out);
450
469
  } else if (obj && typeof obj === "object") {
451
470
  for (const [k, v] of Object.entries(obj)) {
452
- if ((k === "command" || k === "bash") && typeof v === "string") {
471
+ if (isHookCommandField(k) && typeof v === "string") {
453
472
  out.push(v);
454
473
  } else {
455
474
  extractAllHookCommands(v, out);
@@ -466,14 +485,21 @@ interface HookPrefixCheck {
466
485
  firstMissing?: string;
467
486
  }
468
487
 
469
- /** Verify every command in an installed hook file starts with `PAL_AGENT=<agent>`. */
488
+ /** True when a hook command sets PAL_AGENT up front, in POSIX or PowerShell syntax. */
489
+ function setsAgentEnvPrefix(cmd: string, agentName: string): boolean {
490
+ return (
491
+ cmd.startsWith(`PAL_AGENT=${agentName} `) ||
492
+ cmd.startsWith(`$env:PAL_AGENT='${agentName}'; `)
493
+ );
494
+ }
495
+
496
+ /** Verify every command in an installed hook file sets `PAL_AGENT=<agent>` first. */
470
497
  function checkAgentHookPrefix(filePath: string, agentName: string): HookPrefixCheck {
471
498
  if (!existsSync(filePath)) return { ok: false, total: 0, missing: 0 };
472
499
  try {
473
500
  const data = JSON.parse(readFileSync(filePath, "utf-8"));
474
501
  const commands = extractAllHookCommands(data.hooks ?? data);
475
- const prefix = `PAL_AGENT=${agentName} `;
476
- const missing = commands.filter((c) => !c.startsWith(prefix));
502
+ const missing = commands.filter((c) => !setsAgentEnvPrefix(c, agentName));
477
503
  return {
478
504
  ok: commands.length > 0 && missing.length === 0,
479
505
  total: commands.length,
@@ -701,7 +727,7 @@ function doctor(silent = false): DoctorResult {
701
727
  const claude = checkTool("claude");
702
728
  const opencode = checkTool("opencode");
703
729
  const cursor = checkTool("cursor");
704
- const copilot = checkTool("copilot", ["version"]);
730
+ const copilot = checkCopilot();
705
731
  const codex = checkTool("codex");
706
732
  const rtk = checkTool("rtk");
707
733
  const hasAgent =
@@ -7,11 +7,11 @@
7
7
  import { copyFileSync, existsSync, lstatSync, readlinkSync, unlinkSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
9
  import { platform } from "../../hooks/lib/paths";
10
- import { copilotFilename, getSemiStaticSources } from "../../hooks/lib/semi-static";
11
10
  import {
12
11
  log,
13
12
  readJson,
14
13
  removeAgentsFromCopilot,
14
+ removePalContextFiles,
15
15
  removePalDocs,
16
16
  removeSkills,
17
17
  vscodeSettingsFile,
@@ -49,20 +49,13 @@ if (removedAgents.length > 0) {
49
49
  removePalDocs();
50
50
 
51
51
  // --- Remove ~/.copilot/instructions/pal-*.instructions.md ---
52
- for (const src of getSemiStaticSources()) {
53
- try {
54
- unlinkSync(resolve(COPILOT_DIR, "instructions", copilotFilename(src)));
55
- } catch {
56
- /* gone */
57
- }
58
- }
59
- // pal-session.instructions.md is written by LoadContext (not a semi-static source)
60
- try {
61
- unlinkSync(resolve(COPILOT_DIR, "instructions", "pal-session.instructions.md"));
62
- } catch {
63
- /* gone */
64
- }
65
- log.success("Removed ~/.copilot/instructions/pal-*.instructions.md");
52
+ const removedInstructions = removePalContextFiles(
53
+ resolve(COPILOT_DIR, "instructions"),
54
+ ".instructions.md"
55
+ );
56
+ log.success(
57
+ `Removed ${removedInstructions.length} ~/.copilot/instructions/pal-*.instructions.md`
58
+ );
66
59
 
67
60
  // --- Backward compat: remove old copilot-instructions.md symlink if present ---
68
61
  const legacyPath = resolve(COPILOT_DIR, "copilot-instructions.md");
@@ -4,15 +4,15 @@
4
4
  * Removes PAL skill symlinks.
5
5
  */
6
6
 
7
- import { copyFileSync, existsSync, unlinkSync } from "node:fs";
7
+ import { copyFileSync, existsSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
9
  import { assets, palPkg, platform } from "../../hooks/lib/paths";
10
- import { cursorFilename, getSemiStaticSources } from "../../hooks/lib/semi-static";
11
10
  import {
12
11
  loadCursorHooksTemplate,
13
12
  log,
14
13
  readJson,
15
14
  removeAgentsFromCursor,
15
+ removePalContextFiles,
16
16
  removePalDocs,
17
17
  removeSkills,
18
18
  removeStatusline,
@@ -73,19 +73,7 @@ if (existsSync(CLI_CONFIG)) {
73
73
  removeStatusline("cursor");
74
74
 
75
75
  // --- Remove ~/.cursor/rules/pal-*.mdc ---
76
- for (const src of getSemiStaticSources()) {
77
- try {
78
- unlinkSync(resolve(CURSOR_DIR, "rules", cursorFilename(src)));
79
- } catch {
80
- /* gone */
81
- }
82
- }
83
- // Backward compat: remove legacy merged file if present
84
- try {
85
- unlinkSync(resolve(CURSOR_DIR, "rules", "pal-context.mdc"));
86
- } catch {
87
- /* gone */
88
- }
89
- log.success("Removed ~/.cursor/rules/pal-*.mdc");
76
+ const removedRules = removePalContextFiles(resolve(CURSOR_DIR, "rules"), ".mdc");
77
+ log.success(`Removed ${removedRules.length} ~/.cursor/rules/pal-*.mdc`);
90
78
 
91
79
  log.success("Cursor uninstall complete");
@@ -838,6 +838,29 @@ function ensureSymlink(link: string, target: string, type: "dir" | "junction"):
838
838
  symlinkSync(target, link, type);
839
839
  }
840
840
 
841
+ /**
842
+ * Remove every `pal-*` context file with the given suffix from a directory.
843
+ *
844
+ * Globs rather than iterating getSemiStaticSources(): a slug retired from that
845
+ * registry keeps its already-written file on disk, and a registry-driven delete
846
+ * can no longer name it — agents then keep loading retired context forever.
847
+ * Also catches the legacy pre-split filenames without needing a special case.
848
+ */
849
+ export function removePalContextFiles(dir: string, suffix: string): string[] {
850
+ if (!existsSync(dir)) return [];
851
+ const removed: string[] = [];
852
+ for (const file of readdirSync(dir)) {
853
+ if (!file.startsWith("pal-") || !file.endsWith(suffix)) continue;
854
+ try {
855
+ unlinkSync(resolve(dir, file));
856
+ removed.push(file);
857
+ } catch {
858
+ /* gone or not ours to remove */
859
+ }
860
+ }
861
+ return removed;
862
+ }
863
+
841
864
  /** Remove PAL skill symlinks from ~/.pal/skills/ and ~/.claude/skills/ */
842
865
  export function removeSkills(claudeSkillsDir: string): string[] {
843
866
  const skillsDir = assets.skills();