portable-agent-layer 0.84.5 → 0.85.2

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.84.5",
3
+ "version": "0.85.2",
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
@@ -49,6 +49,7 @@ import {
49
49
  import { inference, previewInferenceRoute } from "../hooks/lib/inference";
50
50
  import { logDebug, recentHookErrors } from "../hooks/lib/log";
51
51
  import { ensureRegistered, writeRegistryEntry } from "../hooks/lib/machine";
52
+ import { opencodeBackgroundModel } from "../hooks/lib/opencode-config";
52
53
  import { palHome, palPkg, paths, platform, toPath } from "../hooks/lib/paths";
53
54
  import { auditBindings, describeBindingIssue } from "../hooks/lib/projects";
54
55
  import { telosStatus } from "../hooks/lib/telos-topics";
@@ -1051,6 +1052,14 @@ function doctor(silent = false): DoctorResult {
1051
1052
  ok(`Would route to: ${preview.route} (${preview.reason})`);
1052
1053
  }
1053
1054
  }
1055
+ if (opencode.available) {
1056
+ const model = opencodeBackgroundModel();
1057
+ model
1058
+ ? ok(`opencode background model: ${model}`)
1059
+ : warn(
1060
+ `opencode background model: not pinned — background inference uses the model last picked in the TUI. Pin one with "model" in ${resolve(platform.opencodeDir(), "config.json")}`
1061
+ );
1062
+ }
1054
1063
  if (process.env.PAL_INFERENCE_DISABLED === "1") {
1055
1064
  warn(
1056
1065
  "PAL_INFERENCE_DISABLED=1 — test kill-switch leaked into prod env; every inference call will return failure"
@@ -31,8 +31,9 @@ import {
31
31
  isCursor,
32
32
  isOpencode,
33
33
  } from "./agent";
34
- import { logDebug } from "./log";
34
+ import { logDebug, logError } from "./log";
35
35
  import { HAIKU_MODEL } from "./models";
36
+ import { writeInstructionFreeConfig } from "./opencode-config";
36
37
  import { buildSpawnGuardEnv, getInferenceDepth, SPAWN_GUARD_ENV } from "./spawn-guard";
37
38
  import { findBinaryOnPath } from "./which";
38
39
 
@@ -171,13 +172,7 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
171
172
  const bin = getOpencodeBinary();
172
173
  if (bin) {
173
174
  logDebug("inference", `${tag} route=opencode-spawn agent=${agent}`);
174
- return inferenceViaCliSpawn(
175
- bin,
176
- buildOpencodeArgs(opts),
177
- buildCliPrompt(opts),
178
- opts,
179
- extractOpencodeText
180
- );
175
+ return inferenceViaOpencodeSpawn(bin, opts);
181
176
  }
182
177
  }
183
178
  if (isCopilot()) {
@@ -366,6 +361,34 @@ export function buildOpencodeArgs(_opts: InferenceOptions): string[] {
366
361
  return ["run", "--pure", "--format", "json"];
367
362
  }
368
363
 
364
+ // Without its own AGENTS.md, opencode falls back to ~/.claude/CLAUDE.md, and it
365
+ // lists every Claude and external skill in its tool description.
366
+ const OPENCODE_WITHOUT_EXTERNAL_CONTEXT = {
367
+ OPENCODE_DISABLE_CLAUDE_CODE: "1",
368
+ OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
369
+ };
370
+
371
+ /** Runs from an empty directory so no project AGENTS.md is in reach either. */
372
+ async function inferenceViaOpencodeSpawn(
373
+ bin: string,
374
+ opts: InferenceOptions
375
+ ): Promise<InferenceResult> {
376
+ const dir = await mkdtemp(join(tmpdir(), "pal-opencode-"));
377
+ try {
378
+ writeInstructionFreeConfig(dir);
379
+ return await inferenceViaCliSpawn(
380
+ bin,
381
+ buildOpencodeArgs(opts),
382
+ buildCliPrompt(opts),
383
+ opts,
384
+ extractOpencodeText,
385
+ { cwd: dir, env: { XDG_CONFIG_HOME: dir, ...OPENCODE_WITHOUT_EXTERNAL_CONTEXT } }
386
+ );
387
+ } finally {
388
+ await rm(dir, { recursive: true, force: true });
389
+ }
390
+ }
391
+
369
392
  /**
370
393
  * Build the argv for `cursor-agent -p …` from inference options. Pure.
371
394
  *
@@ -513,7 +536,8 @@ async function singleCliAttempt(
513
536
  args: string[],
514
537
  stdinInput: string,
515
538
  env: NodeJS.ProcessEnv,
516
- timeout: number
539
+ timeout: number,
540
+ cwd?: string
517
541
  ): Promise<RawSpawnResult> {
518
542
  return new Promise<RawSpawnResult>((resolve) => {
519
543
  let stdout = "";
@@ -530,13 +554,14 @@ async function singleCliAttempt(
530
554
  try {
531
555
  proc = Bun.spawn([binary, ...args], {
532
556
  env,
557
+ cwd,
533
558
  stdin: "pipe",
534
559
  stdout: "pipe",
535
560
  stderr: "pipe",
536
561
  windowsHide: true,
537
562
  });
538
563
  } catch (err) {
539
- void logError("inference:spawn", err);
564
+ logError("inference:spawn", err);
540
565
  finish({ code: null, stdout: "", stderr: "", timedOut: false });
541
566
  return;
542
567
  }
@@ -563,7 +588,7 @@ async function singleCliAttempt(
563
588
  if (stdinInput) stdinWriter.write(stdinInput);
564
589
  stdinWriter.end();
565
590
  } catch (err) {
566
- void logError("inference:stdin", err);
591
+ logError("inference:stdin", err);
567
592
  }
568
593
  }
569
594
 
@@ -593,6 +618,11 @@ async function singleCliAttempt(
593
618
  });
594
619
  }
595
620
 
621
+ interface SpawnPlace {
622
+ cwd?: string;
623
+ env?: Record<string, string>;
624
+ }
625
+
596
626
  /**
597
627
  * Generic CLI dispatcher: spawn `binary args`, write stdinInput to stdin (may be
598
628
  * empty for argv-only CLIs like codex), capture stdout, retry once on empty-abort.
@@ -603,10 +633,11 @@ async function inferenceViaCliSpawn(
603
633
  args: string[],
604
634
  stdinInput: string,
605
635
  opts: InferenceOptions,
606
- extractText?: (rawStdout: string) => string
636
+ extractText?: (rawStdout: string) => string,
637
+ place: SpawnPlace = {}
607
638
  ): Promise<InferenceResult> {
608
639
  const timeout = opts.timeout ?? 15000;
609
- const env = buildSpawnGuardEnv(process.env);
640
+ const env = { ...buildSpawnGuardEnv(process.env), ...place.env };
610
641
  const started = Date.now();
611
642
  const caller = opts.caller ?? "anonymous";
612
643
  const session = opts.sessionId ?? "-";
@@ -616,7 +647,7 @@ async function inferenceViaCliSpawn(
616
647
  const binaryName = basename(binary).replace(/\.(cmd|bat|exe|com)$/i, "");
617
648
 
618
649
  // Attempt 1
619
- let attempt = await singleCliAttempt(binary, args, stdinInput, env, timeout);
650
+ let attempt = await singleCliAttempt(binary, args, stdinInput, env, timeout, place.cwd);
620
651
 
621
652
  // Universal retry on empty-output exit≠0 (correlates strongly with burst-
622
653
  // concurrency races — the binary silently aborts without writing to either
@@ -633,7 +664,7 @@ async function inferenceViaCliSpawn(
633
664
  `${tag} retry: empty-abort binary=${binaryName} exit=${attempt.code} after ${Date.now() - started}ms, jitter=${jitterMs}ms`
634
665
  );
635
666
  await new Promise((r) => setTimeout(r, jitterMs));
636
- attempt = await singleCliAttempt(binary, args, stdinInput, env, timeout);
667
+ attempt = await singleCliAttempt(binary, args, stdinInput, env, timeout, place.cwd);
637
668
  }
638
669
 
639
670
  const elapsedMs = Date.now() - started;
@@ -646,14 +677,11 @@ async function inferenceViaCliSpawn(
646
677
  };
647
678
 
648
679
  if (attempt.timedOut) {
649
- void logError(
650
- "inference:spawn",
651
- `${tag} timeout binary=${binaryName} after ${timeout}ms`
652
- );
680
+ logError("inference:spawn", `${tag} timeout binary=${binaryName} after ${timeout}ms`);
653
681
  return finish({ success: false });
654
682
  }
655
683
  if (attempt.code !== 0) {
656
- void logError(
684
+ logError(
657
685
  "inference:spawn",
658
686
  `${tag} exited=${attempt.code} binary=${binaryName} argv=${JSON.stringify(args)} stderr(${attempt.stderr.length})=${attempt.stderr.slice(0, 300)} stdout(${attempt.stdout.length})=${attempt.stdout.slice(0, 300)}`
659
687
  );
@@ -665,7 +693,7 @@ async function inferenceViaCliSpawn(
665
693
  if (!text) {
666
694
  // Extraction returned empty — the binary succeeded but our extractor found
667
695
  // no usable text. Log the raw stdout so we can see what was actually emitted.
668
- void logError(
696
+ logError(
669
697
  "inference:spawn",
670
698
  `${tag} extract-empty binary=${binaryName} rawStdout(${rawText.length})=${rawText.slice(0, 500)}`
671
699
  );
@@ -679,11 +707,6 @@ async function inferenceViaCliSpawn(
679
707
  return finish({ success: true, output: text });
680
708
  }
681
709
 
682
- async function logError(scope: string, err: unknown): Promise<void> {
683
- const { logError: log } = await import("./log");
684
- log(scope, err);
685
- }
686
-
687
710
  // ─────────────────────────────────────────────────────────────────────────────
688
711
  // Anthropic API path — used when no claude binary is available, or when the
689
712
  // active agent is not claude. Preserves the original PAL inference behavior.
@@ -733,7 +756,7 @@ async function inferenceViaApi(opts: InferenceOptions): Promise<InferenceResult>
733
756
 
734
757
  if (!response.ok) {
735
758
  const errBody = await response.text().catch(() => "");
736
- await logError("inference", `HTTP ${response.status}: ${errBody.slice(0, 200)}`);
759
+ logError("inference", `HTTP ${response.status}: ${errBody.slice(0, 200)}`);
737
760
  return { success: false };
738
761
  }
739
762
 
@@ -752,7 +775,7 @@ async function inferenceViaApi(opts: InferenceOptions): Promise<InferenceResult>
752
775
 
753
776
  return { success: true, output: text, usage };
754
777
  } catch (err) {
755
- await logError("inference", err);
778
+ logError("inference", err);
756
779
  return { success: false };
757
780
  }
758
781
  }
@@ -813,10 +836,7 @@ async function inferenceViaOpenAiApi(opts: InferenceOptions): Promise<InferenceR
813
836
 
814
837
  if (!response.ok) {
815
838
  const errBody = await response.text().catch(() => "");
816
- await logError(
817
- "inference:openai",
818
- `HTTP ${response.status}: ${errBody.slice(0, 200)}`
819
- );
839
+ logError("inference:openai", `HTTP ${response.status}: ${errBody.slice(0, 200)}`);
820
840
  return { success: false };
821
841
  }
822
842
 
@@ -840,7 +860,7 @@ async function inferenceViaOpenAiApi(opts: InferenceOptions): Promise<InferenceR
840
860
 
841
861
  return { success: true, output: text, usage };
842
862
  } catch (err) {
843
- await logError("inference:openai", err);
863
+ logError("inference:openai", err);
844
864
  return { success: false };
845
865
  }
846
866
  }
@@ -9,13 +9,16 @@ import { extractKeywords, similarity } from "./text-similarity";
9
9
  export type Reaction = "corrected" | "repeated" | "approved" | "new-topic" | "follow-up";
10
10
 
11
11
  const CORRECTION_RE =
12
- /^(?:(?:no|nope|wrong)(?:[,.!]|$)|(?:that'?s|this is|it'?s) (?:wrong|not)\b|not what i\b|i said\b|i told you\b|you forgot\b|you missed\b|still (?:broken|failing|wrong)\b)/i;
12
+ /^(?:(?:no|nope|wrong)(?:[,.!]|$)|(?:that'?s|this is|it'?s) (?:wrong|not)\b|not what i\b|i said\b|i told you\b|you forgot\b|you missed\b|still (?:broken|failing|wrong)\b|i don'?t see (?:the|your) (?!(?:problem|issue|point|harm|difference|need|reason)\b))/i;
13
+
14
+ const CLAIM_DISPUTED_RE =
15
+ /\byou(?:'re| are) (?:partially |partly |completely |totally )?wrong\b|\b(?:isn'?t|wasn'?t|aren'?t|weren'?t) (?:added|included|fixed|pushed|committed|applied|merged)\b/i;
13
16
 
14
17
  const APPROVAL_RE =
15
- /^(?:good|great|nice|perfect|cool|amazing|awesome|excellent|brilliant|works|it works|thanks|thank you|thx|y+e+s+|yep|yeah|yup|sure|exactly|correct|agreed|lgtm|well done|love it|sounds good|looks good|that'?s it|(?:it|this|that) (?:is|'s) (?:fine|good|great|right))\b/i;
18
+ /^(?:(?:understood|got it|ok(?:ay)?),?\s+)?(?:good|great|nice|perfect|cool|amazing|awesome|excellent|brilliant|works|it works|thanks|thank you|thx|y+e+s+|yep|yeah|yup|sure|exactly|correct|agreed|lgtm|well done|love it|sounds good|looks good|that'?s it|(?:it|this|that) (?:is|'s) (?:fine|good|great|right))\b/i;
16
19
 
17
20
  const GO_AHEAD_RE =
18
- /^(?:go ahead|go|do it|proceed|continue|merge|push|ship|commit|build|start|fix (?:all|them|both)|approved|let'?s (?:see|do|go))\b/i;
21
+ /^(?:go ahead|go|do it|proceed|continue|merge|push|ship|commit|build|start|fix (?:all|them|both)|approved|let'?s (?:see|do|go|build|start|fix|ship)|(?:add|do|fix) (?:it )?(?:pls|please))\b/i;
19
22
 
20
23
  const NEW_TOPIC_RE =
21
24
  /^(?:(?:ok(?:ay)?|btw|also),?\s+)?(?:new (?:\w+ ){0,2}(?:task|topic|question|thing)|(?:different|unrelated|separate|another) (?:task|topic|question|thing)|unrelated\b|switching (?:gears|topics?)|change of (?:topic|subject))/i;
@@ -23,11 +26,13 @@ const NEW_TOPIC_RE =
23
26
  const GO_AHEAD_MAX_WORDS = 6;
24
27
  const BARE_OK_RE = /^(?:ok(?:ay)?|k)[,.!]?(?:\s+\S+){0,2}$/i;
25
28
  const HEDGE_RE = /\b(?:but|however|though|except|instead)\b/i;
29
+ const FAILURE_RE = /\b(?:fail(?:ed|s|ing)?|broken|broke|errors?|crash(?:ed|es)?)\b/i;
26
30
  const REPEAT_SIMILARITY = 0.6;
27
31
  const REPEAT_MIN_KEYWORDS = 5;
28
32
 
29
33
  export function isCorrection(text: string): boolean {
30
- return CORRECTION_RE.test(text.trim());
34
+ const trimmed = text.trim();
35
+ return CORRECTION_RE.test(trimmed) || CLAIM_DISPUTED_RE.test(trimmed);
31
36
  }
32
37
 
33
38
  export function isRepeat(text: string, previous: string | undefined): boolean {
@@ -39,21 +44,21 @@ function isShortGoAhead(sentence: string): boolean {
39
44
  return GO_AHEAD_RE.test(sentence) && sentence.split(/\s+/).length <= GO_AHEAD_MAX_WORDS;
40
45
  }
41
46
 
42
- function lastSentence(text: string): string {
43
- return (
44
- text
45
- .split(/[.!\n]\s*/)
46
- .filter(Boolean)
47
- .at(-1)
48
- ?.trim() ?? ""
49
- );
47
+ function sentences(text: string): string[] {
48
+ return text
49
+ .split(/[.!\n]\s*/)
50
+ .map((s) => s.trim())
51
+ .filter(Boolean);
50
52
  }
51
53
 
52
54
  function isApproval(text: string): boolean {
53
55
  const trimmed = text.trim();
54
- if (trimmed.includes("?") || HEDGE_RE.test(trimmed.split(/[.!?\n]/)[0])) return false;
56
+ if (trimmed.includes("?") || FAILURE_RE.test(trimmed)) return false;
57
+ if (HEDGE_RE.test(trimmed.split(/[.!?\n]/)[0])) return false;
55
58
  if (APPROVAL_RE.test(trimmed) || BARE_OK_RE.test(trimmed)) return true;
56
- return isShortGoAhead(trimmed) || isShortGoAhead(lastSentence(trimmed));
59
+ const all = sentences(trimmed);
60
+ if (isShortGoAhead(all.at(-1) ?? "")) return true;
61
+ return isShortGoAhead(all[0] ?? "") && !HEDGE_RE.test(trimmed);
57
62
  }
58
63
 
59
64
  /** Word overlap was measured against real messages and does not separate topics; only saying so does. */
@@ -0,0 +1,50 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { platform } from "./paths";
4
+
5
+ const GLOBAL_CONFIG_FILES = ["config.json", "opencode.json", "opencode.jsonc"];
6
+
7
+ type OpencodeConfig = Record<string, unknown>;
8
+
9
+ function readConfig(name: string): OpencodeConfig | null {
10
+ try {
11
+ const config = Bun.JSONC.parse(
12
+ readFileSync(resolve(platform.opencodeDir(), name), "utf-8")
13
+ );
14
+ return config && typeof config === "object" ? (config as OpencodeConfig) : null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ function pinnedModel(config: OpencodeConfig | null): string | null {
21
+ return typeof config?.model === "string" && config.model ? config.model : null;
22
+ }
23
+
24
+ /** Without a pinned model, `opencode run` falls back to whatever was last picked in the TUI. */
25
+ export function opencodeBackgroundModel(): string | null {
26
+ for (const name of GLOBAL_CONFIG_FILES) {
27
+ const model = pinnedModel(readConfig(name));
28
+ if (model) return model;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function withoutInstructions(config: OpencodeConfig): OpencodeConfig {
34
+ const { instructions: _, ...rest } = config;
35
+ return rest;
36
+ }
37
+
38
+ /**
39
+ * opencode loads the global AGENTS.md and every `instructions` file into each run,
40
+ * so PAL's identity and output format would reach background inference too.
41
+ */
42
+ export function writeInstructionFreeConfig(configHome: string): void {
43
+ const dir = resolve(configHome, "opencode");
44
+ mkdirSync(dir, { recursive: true });
45
+ for (const name of GLOBAL_CONFIG_FILES) {
46
+ const config = readConfig(name);
47
+ if (config)
48
+ writeFileSync(resolve(dir, name), JSON.stringify(withoutInstructions(config)));
49
+ }
50
+ }
@@ -25,6 +25,10 @@ async function lib<T>(mod: string): Promise<T> {
25
25
 
26
26
  type TranscriptMessage = { role: string; content: string };
27
27
 
28
+ function isSessionEvent(type: string): boolean {
29
+ return type.startsWith("session.");
30
+ }
31
+
28
32
  const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
29
33
  // Pre-load shared modules
30
34
  const { buildSystemReminder } =
@@ -125,7 +129,7 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
125
129
  // --- Session events: start and stop handling ---
126
130
  event: async ({ event }) => {
127
131
  if (isPalSpawnedInference()) return;
128
- logDebug("opencode:event", `Event: ${event.type}`);
132
+ if (isSessionEvent(event.type)) logDebug("opencode:event", `Event: ${event.type}`);
129
133
 
130
134
  if (event.type === "server.instance.disposed") {
131
135
  const { autoUpdateOnClose } =
@@ -139,7 +143,7 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
139
143
  regenerateIfNeeded();
140
144
  }
141
145
 
142
- if (event.type === "session.idle" || event.type === "session.diff") {
146
+ if (event.type === "session.idle") {
143
147
  logDebug("opencode:event", "Running stop handlers...");
144
148
  try {
145
149
  const sessionID = (event as { properties?: { sessionID?: string } })?.properties