portable-agent-layer 0.39.0 → 0.41.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 +37 -16
- package/assets/templates/PAL/MEMORY_SYSTEM.md +63 -17
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +81 -8
- package/assets/templates/hooks.copilot.json +4 -4
- package/assets/templates/settings.claude.json +7 -7
- package/package.json +8 -5
- package/src/cli/index.ts +282 -22
- package/src/cli/migrate.ts +5 -48
- package/src/hooks/CompactRecover.ts +4 -0
- package/src/hooks/LoadContext.ts +13 -8
- package/src/hooks/PreCompactPersist.ts +4 -0
- package/src/hooks/StopOrchestrator.ts +18 -6
- package/src/hooks/UserPromptOrchestrator.ts +7 -1
- package/src/hooks/handlers/auto-graduate.ts +8 -0
- package/src/hooks/handlers/failure-principle.ts +122 -0
- package/src/hooks/handlers/rating.ts +57 -26
- package/src/hooks/handlers/session-intelligence.ts +26 -6
- package/src/hooks/handlers/session-name.ts +13 -21
- package/src/hooks/lib/agent.ts +28 -13
- package/src/hooks/lib/detached-inference.ts +39 -0
- package/src/hooks/lib/graduation.ts +1 -0
- package/src/hooks/lib/inference.ts +786 -5
- package/src/hooks/lib/log.ts +60 -12
- package/src/hooks/lib/notify.ts +1 -0
- package/src/hooks/lib/projects.ts +52 -0
- package/src/hooks/lib/security.ts +5 -0
- package/src/hooks/lib/spawn-guard.ts +68 -0
- package/src/hooks/lib/stop.ts +77 -79
- package/src/targets/opencode/plugin.ts +13 -0
- package/src/tools/agent/project.ts +4 -42
- package/src/tools/self-model.ts +1 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failure-principle handler — extracts an actionable principle from a low-rated
|
|
3
|
+
* session via inference, then persists the failure record.
|
|
4
|
+
*
|
|
5
|
+
* Detached from the Stop hook (lib/stop.ts) because claude --print cold-start
|
|
6
|
+
* can exceed the Stop hook's reasonable budget. Parent writes pending data +
|
|
7
|
+
* transcript to tmp files, spawns this script with both paths, and returns
|
|
8
|
+
* immediately. Child reads, runs inference, calls captureFailure, and unlinks
|
|
9
|
+
* the tmp files.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { readFile, unlink } from "node:fs/promises";
|
|
14
|
+
import { extractContent, parseMessages } from "../lib/transcript";
|
|
15
|
+
import { captureFailure } from "./failure";
|
|
16
|
+
|
|
17
|
+
interface PendingFailure {
|
|
18
|
+
rating: number;
|
|
19
|
+
context: string;
|
|
20
|
+
detailedContext?: string;
|
|
21
|
+
principle?: string;
|
|
22
|
+
responsePreview?: string;
|
|
23
|
+
userPreview?: string;
|
|
24
|
+
cwd?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Inference the principle (if missing) and persist the failure record.
|
|
29
|
+
* Reads pending data + transcript from the provided tmp paths and unlinks them.
|
|
30
|
+
*/
|
|
31
|
+
async function processFailurePrinciple(
|
|
32
|
+
pendingPath: string,
|
|
33
|
+
transcriptPath: string
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
const { logDebug, logError } = await import("../lib/log");
|
|
36
|
+
try {
|
|
37
|
+
if (!existsSync(pendingPath) || !existsSync(transcriptPath)) {
|
|
38
|
+
logError(
|
|
39
|
+
"failure-principle",
|
|
40
|
+
`missing input: pending=${existsSync(pendingPath)} transcript=${existsSync(transcriptPath)}`
|
|
41
|
+
);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const pending = JSON.parse(await readFile(pendingPath, "utf-8")) as PendingFailure;
|
|
46
|
+
const transcript = await readFile(transcriptPath, "utf-8");
|
|
47
|
+
logDebug("failure-principle", `processing rating=${pending.rating}`);
|
|
48
|
+
|
|
49
|
+
let { principle, detailedContext } = pending;
|
|
50
|
+
if (!principle) {
|
|
51
|
+
try {
|
|
52
|
+
const { inference } = await import("../lib/inference");
|
|
53
|
+
const msgs = parseMessages(transcript);
|
|
54
|
+
const recent = msgs
|
|
55
|
+
.slice(-10)
|
|
56
|
+
.map((m) => `${m.role.toUpperCase()}: ${extractContent(m).slice(0, 300)}`)
|
|
57
|
+
.join("\n\n");
|
|
58
|
+
|
|
59
|
+
const result = await inference({
|
|
60
|
+
system: `Analyze this failed AI interaction. The user rated it ${pending.rating}/10.
|
|
61
|
+
|
|
62
|
+
Return JSON:
|
|
63
|
+
{
|
|
64
|
+
"principle": "<one actionable rule the AI should follow, 10-20 words. Start with a verb: 'Verify...', 'Always...', 'Never...', 'Ask before...'>",
|
|
65
|
+
"detailed_context": "<what went wrong and why, 50-150 words>"
|
|
66
|
+
}`,
|
|
67
|
+
user: `User feedback: ${pending.context}\n\nConversation:\n${recent}`,
|
|
68
|
+
maxTokens: 400,
|
|
69
|
+
timeout: 60000,
|
|
70
|
+
jsonSchema: {
|
|
71
|
+
type: "object" as const,
|
|
72
|
+
properties: {
|
|
73
|
+
principle: { type: "string" as const },
|
|
74
|
+
detailed_context: { type: "string" as const },
|
|
75
|
+
},
|
|
76
|
+
required: ["principle", "detailed_context"],
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
},
|
|
79
|
+
caller: "failure-principle",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (result.success && result.output) {
|
|
83
|
+
const parsed = JSON.parse(result.output) as {
|
|
84
|
+
principle?: string;
|
|
85
|
+
detailed_context?: string;
|
|
86
|
+
};
|
|
87
|
+
principle = parsed.principle || undefined;
|
|
88
|
+
detailedContext ??= parsed.detailed_context || undefined;
|
|
89
|
+
} else {
|
|
90
|
+
logError("failure-principle", `inference failed (no output)`);
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
logError("failure-principle:inference", err);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await captureFailure(
|
|
98
|
+
pending.rating,
|
|
99
|
+
pending.context,
|
|
100
|
+
transcript,
|
|
101
|
+
detailedContext,
|
|
102
|
+
principle,
|
|
103
|
+
pending.cwd
|
|
104
|
+
);
|
|
105
|
+
logDebug("failure-principle", "captureFailure done");
|
|
106
|
+
} catch (err) {
|
|
107
|
+
logError("failure-principle", err);
|
|
108
|
+
} finally {
|
|
109
|
+
await unlink(pendingPath).catch(() => {});
|
|
110
|
+
await unlink(transcriptPath).catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Detached child entry point
|
|
115
|
+
if (process.argv[2] === "--run") {
|
|
116
|
+
const pendingPath = process.argv[3];
|
|
117
|
+
const transcriptPath = process.argv[4];
|
|
118
|
+
if (pendingPath && transcriptPath) {
|
|
119
|
+
await processFailurePrinciple(pendingPath, transcriptPath);
|
|
120
|
+
}
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
|
-
import {
|
|
13
|
+
import { spawnDetachedInference } from "../lib/detached-inference";
|
|
14
|
+
import { canInfer, inference } from "../lib/inference";
|
|
14
15
|
import { paths } from "../lib/paths";
|
|
15
16
|
import { emitRating } from "../lib/signals";
|
|
16
17
|
import { now } from "../lib/time";
|
|
@@ -284,13 +285,10 @@ function handleRating(
|
|
|
284
285
|
|
|
285
286
|
// ── Implicit Sentiment ──
|
|
286
287
|
|
|
287
|
-
|
|
288
|
-
message: string,
|
|
289
|
-
sessionId?: string
|
|
290
|
-
): Promise<void> {
|
|
288
|
+
function handleImplicitSentiment(message: string, sessionId?: string): void {
|
|
291
289
|
const trimmed = message.trim();
|
|
292
290
|
|
|
293
|
-
// Fast-path: short praise -> rating 8
|
|
291
|
+
// Fast-path: short praise -> rating 8 (synchronous, no inference)
|
|
294
292
|
if (isPraise(trimmed)) {
|
|
295
293
|
handleRating(
|
|
296
294
|
8,
|
|
@@ -311,27 +309,48 @@ async function handleImplicitSentiment(
|
|
|
311
309
|
if (trimmed.length < 5 || trimmed.length > 500) return;
|
|
312
310
|
if (/^[/$`{]/.test(trimmed) || trimmed.includes("\n\n")) return;
|
|
313
311
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
});
|
|
312
|
+
// Inference path — detach to background. claude --print has 3-5s of cold-start
|
|
313
|
+
// overhead per call; running inline would block UserPromptSubmit and exceed
|
|
314
|
+
// any reasonable in-line budget. Uses the shared detach helper.
|
|
315
|
+
if (!canInfer()) return;
|
|
316
|
+
const msgB64 = Buffer.from(trimmed.slice(0, 800)).toString("base64");
|
|
317
|
+
spawnDetachedInference(
|
|
318
|
+
import.meta.filename,
|
|
319
|
+
["--sentiment", sessionId ?? "", msgB64],
|
|
320
|
+
"rating"
|
|
321
|
+
);
|
|
322
|
+
}
|
|
326
323
|
|
|
327
|
-
|
|
324
|
+
/**
|
|
325
|
+
* Background sentiment mode: called via --sentiment flag from a detached subprocess.
|
|
326
|
+
* Runs the heavy inference, parses the result, and writes the rating if confident.
|
|
327
|
+
*/
|
|
328
|
+
async function runSentimentInferenceAndStore(
|
|
329
|
+
message: string,
|
|
330
|
+
sessionId?: string
|
|
331
|
+
): Promise<void> {
|
|
332
|
+
try {
|
|
333
|
+
const trimmed = message.trim();
|
|
334
|
+
const lastResponse = getLastResponse(sessionId).slice(0, 300);
|
|
335
|
+
const contextBlock = lastResponse
|
|
336
|
+
? `CONTEXT (last AI response excerpt):\n${lastResponse}\n\nCURRENT USER MESSAGE:\n${trimmed.slice(0, 300)}`
|
|
337
|
+
: trimmed.slice(0, 300);
|
|
338
|
+
|
|
339
|
+
const result = await inference({
|
|
340
|
+
system: SENTIMENT_SYSTEM_PROMPT,
|
|
341
|
+
user: contextBlock,
|
|
342
|
+
maxTokens: 500,
|
|
343
|
+
timeout: 60000,
|
|
344
|
+
jsonSchema: SENTIMENT_SCHEMA,
|
|
345
|
+
caller: "rating",
|
|
346
|
+
sessionId,
|
|
347
|
+
});
|
|
328
348
|
|
|
329
|
-
|
|
349
|
+
if (result.usage) logTokenUsage("rating", result.usage);
|
|
350
|
+
if (!result.success || !result.output) return;
|
|
330
351
|
|
|
331
|
-
try {
|
|
332
352
|
const parsed = JSON.parse(result.output) as SentimentResult;
|
|
333
353
|
|
|
334
|
-
// Skip if no sentiment detected or low confidence
|
|
335
354
|
if (parsed.rating === null) return;
|
|
336
355
|
if (parsed.confidence < MIN_CONFIDENCE) return;
|
|
337
356
|
|
|
@@ -349,13 +368,13 @@ async function handleImplicitSentiment(
|
|
|
349
368
|
}
|
|
350
369
|
} catch (err) {
|
|
351
370
|
const { logError } = await import("../lib/log");
|
|
352
|
-
logError("rating:
|
|
371
|
+
logError("rating:sentiment-child", err);
|
|
353
372
|
}
|
|
354
373
|
}
|
|
355
374
|
|
|
356
375
|
// ── Main Export ──
|
|
357
376
|
|
|
358
|
-
export
|
|
377
|
+
export function captureRating(message: string, sessionId?: string): void {
|
|
359
378
|
// Strip IDE/system-injected tags to recover raw user text
|
|
360
379
|
const cleaned = stripInjectedTags(message);
|
|
361
380
|
|
|
@@ -374,6 +393,18 @@ export async function captureRating(message: string, sessionId?: string): Promis
|
|
|
374
393
|
return;
|
|
375
394
|
}
|
|
376
395
|
|
|
377
|
-
// Path 2: Implicit sentiment
|
|
378
|
-
|
|
396
|
+
// Path 2: Implicit sentiment — fast-paths run synchronously, the inference
|
|
397
|
+
// path detaches to a background bun subprocess (mirrors session-name).
|
|
398
|
+
handleImplicitSentiment(cleaned, sessionId);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Background sentiment entry point
|
|
402
|
+
if (process.argv[2] === "--sentiment") {
|
|
403
|
+
const sid = process.argv[3];
|
|
404
|
+
const msgB64 = process.argv[4];
|
|
405
|
+
if (msgB64) {
|
|
406
|
+
const msg = Buffer.from(msgB64, "base64").toString("utf-8");
|
|
407
|
+
await runSentimentInferenceAndStore(msg, sid === "" ? undefined : sid);
|
|
408
|
+
}
|
|
409
|
+
process.exit(0);
|
|
379
410
|
}
|
|
@@ -13,7 +13,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
13
13
|
import { unlink, writeFile } from "node:fs/promises";
|
|
14
14
|
import { resolve } from "node:path";
|
|
15
15
|
import { stringify } from "../lib/frontmatter";
|
|
16
|
-
import {
|
|
16
|
+
import { canInfer, inference } from "../lib/inference";
|
|
17
17
|
import { categorizeLearning } from "../lib/learning-category";
|
|
18
18
|
import { logDebug, logError } from "../lib/log";
|
|
19
19
|
import { ensureDir, paths } from "../lib/paths";
|
|
@@ -124,7 +124,7 @@ interface IntelligenceOutput {
|
|
|
124
124
|
|
|
125
125
|
// ── Main handler ──
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
async function captureSessionIntelligence(
|
|
128
128
|
transcript: string,
|
|
129
129
|
sessionId?: string
|
|
130
130
|
): Promise<void> {
|
|
@@ -137,9 +137,9 @@ export async function captureSessionIntelligence(
|
|
|
137
137
|
if (prev && messages.length - prev.messageCount < MIN_NEW_MESSAGES) return;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
// Skip if no API key
|
|
141
|
-
if (!
|
|
142
|
-
logDebug("session-intelligence", "Skipped: no
|
|
140
|
+
// Skip if no inference path is available (no CLI binary AND no API key)
|
|
141
|
+
if (!canInfer()) {
|
|
142
|
+
logDebug("session-intelligence", "Skipped: canInfer() false (no CLI + no API key)");
|
|
143
143
|
return;
|
|
144
144
|
}
|
|
145
145
|
|
|
@@ -178,8 +178,10 @@ export async function captureSessionIntelligence(
|
|
|
178
178
|
].join("\n"),
|
|
179
179
|
user: `User messages:\n${numberedMessages}\n\nLast AI response:\n${assistantWindow}`,
|
|
180
180
|
maxTokens: 350,
|
|
181
|
-
timeout:
|
|
181
|
+
timeout: 60000,
|
|
182
182
|
jsonSchema: INTELLIGENCE_SCHEMA,
|
|
183
|
+
caller: "session-intelligence",
|
|
184
|
+
sessionId,
|
|
183
185
|
});
|
|
184
186
|
|
|
185
187
|
if (result.usage) logTokenUsage("session-intelligence", result.usage);
|
|
@@ -246,3 +248,21 @@ export async function captureSessionIntelligence(
|
|
|
246
248
|
if (sessionId) markCaptured(sessionId, filepath, messages.length);
|
|
247
249
|
logDebug("session-intelligence", `Learning captured: ${title}`);
|
|
248
250
|
}
|
|
251
|
+
|
|
252
|
+
// Detached child entry point — re-reads transcript from tmp path, then unlinks it.
|
|
253
|
+
if (process.argv[2] === "--run") {
|
|
254
|
+
const sid = process.argv[3];
|
|
255
|
+
const transcriptPath = process.argv[4];
|
|
256
|
+
if (transcriptPath) {
|
|
257
|
+
const { readFile, unlink } = await import("node:fs/promises");
|
|
258
|
+
try {
|
|
259
|
+
const transcript = await readFile(transcriptPath, "utf-8");
|
|
260
|
+
await captureSessionIntelligence(transcript, sid === "" ? undefined : sid);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
logError("session-intelligence:run", err);
|
|
263
|
+
} finally {
|
|
264
|
+
await unlink(transcriptPath).catch(() => {});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
process.exit(0);
|
|
268
|
+
}
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* This avoids the 1-5s inference latency that previously blocked every first prompt.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { spawnDetachedInference } from "../lib/detached-inference";
|
|
13
|
+
import { canInfer, inference } from "../lib/inference";
|
|
14
14
|
import { logDebug, logError } from "../lib/log";
|
|
15
15
|
import {
|
|
16
16
|
extractFallbackName,
|
|
@@ -41,24 +41,14 @@ export async function captureSessionName(
|
|
|
41
41
|
writeSessionName(sessionId, name);
|
|
42
42
|
logDebug("session-name", `Named from prompt: "${name}"`);
|
|
43
43
|
|
|
44
|
-
// Spawn detached background process to upgrade
|
|
45
|
-
if (!
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
detached: true,
|
|
53
|
-
stdio: "ignore",
|
|
54
|
-
env: { ...process.env, CLAUDECODE: undefined },
|
|
55
|
-
}
|
|
56
|
-
);
|
|
57
|
-
child.unref();
|
|
58
|
-
logDebug("session-name", "Spawned background Haiku upgrade");
|
|
59
|
-
} catch {
|
|
60
|
-
// Non-critical — deterministic name is already stored
|
|
61
|
-
}
|
|
44
|
+
// Spawn detached background process to upgrade via inference
|
|
45
|
+
if (!canInfer()) return;
|
|
46
|
+
const promptB64 = Buffer.from(message.slice(0, 800)).toString("base64");
|
|
47
|
+
spawnDetachedInference(
|
|
48
|
+
import.meta.filename,
|
|
49
|
+
["--upgrade", sessionId, promptB64, name],
|
|
50
|
+
"session-name"
|
|
51
|
+
);
|
|
62
52
|
}
|
|
63
53
|
|
|
64
54
|
/**
|
|
@@ -80,7 +70,9 @@ async function upgradeWithInference(
|
|
|
80
70
|
system: NAME_PROMPT,
|
|
81
71
|
user: `Generate a 4-word title for: "${promptText}"`,
|
|
82
72
|
maxTokens: 20,
|
|
83
|
-
timeout:
|
|
73
|
+
timeout: 60000,
|
|
74
|
+
caller: "session-name",
|
|
75
|
+
sessionId,
|
|
84
76
|
});
|
|
85
77
|
|
|
86
78
|
if (result.usage) logTokenUsage("session-name", result.usage);
|
package/src/hooks/lib/agent.ts
CHANGED
|
@@ -1,27 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent detection and output format adapters.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Each supported agent (Claude Code, Cursor, Codex, Copilot, opencode) uses a
|
|
5
|
+
* different JSON contract for hook I/O and a different mechanism for spawning
|
|
6
|
+
* one-shot subscription-backed inference. These helpers identify which agent
|
|
7
|
+
* is currently running PAL so downstream code can dispatch accordingly.
|
|
8
|
+
*
|
|
9
|
+
* Primary signal: PAL_AGENT env var, set by every install template/plugin in
|
|
10
|
+
* `assets/templates/*` and `src/targets/opencode/plugin.ts`. IDE-provided env
|
|
11
|
+
* vars are used as secondary fallbacks for environments that forward them.
|
|
6
12
|
*/
|
|
7
13
|
|
|
8
|
-
type AgentType = "claude" | "cursor" | "codex";
|
|
14
|
+
type AgentType = "claude" | "cursor" | "codex" | "copilot" | "opencode";
|
|
15
|
+
|
|
16
|
+
const KNOWN_AGENTS: ReadonlySet<AgentType> = new Set([
|
|
17
|
+
"claude",
|
|
18
|
+
"cursor",
|
|
19
|
+
"codex",
|
|
20
|
+
"copilot",
|
|
21
|
+
"opencode",
|
|
22
|
+
]);
|
|
9
23
|
|
|
10
|
-
/** Detect which agent is running
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (process.env.PAL_AGENT === "codex") return "codex";
|
|
17
|
-
// Fallbacks for environments that do forward IDE env vars
|
|
24
|
+
/** Detect which agent is currently running PAL. Defaults to "claude". */
|
|
25
|
+
export function getActiveAgent(): AgentType {
|
|
26
|
+
const explicit = process.env.PAL_AGENT;
|
|
27
|
+
if (explicit && KNOWN_AGENTS.has(explicit as AgentType)) {
|
|
28
|
+
return explicit as AgentType;
|
|
29
|
+
}
|
|
18
30
|
if (process.env.CURSOR_VERSION) return "cursor";
|
|
19
31
|
if (process.env.CODEX_CLI_VERSION ?? process.env.OPENAI_CODEX) return "codex";
|
|
20
32
|
return "claude";
|
|
21
33
|
}
|
|
22
34
|
|
|
23
|
-
export const
|
|
24
|
-
export const
|
|
35
|
+
export const isClaude = () => getActiveAgent() === "claude";
|
|
36
|
+
export const isCursor = () => getActiveAgent() === "cursor";
|
|
37
|
+
export const isCodex = () => getActiveAgent() === "codex";
|
|
38
|
+
export const isCopilot = () => getActiveAgent() === "copilot";
|
|
39
|
+
export const isOpencode = () => getActiveAgent() === "opencode";
|
|
25
40
|
|
|
26
41
|
/**
|
|
27
42
|
* Format a "block this action" response for the current agent.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared spawn helper for detached background inference calls.
|
|
3
|
+
*
|
|
4
|
+
* PAL hooks that need to run an inference but cannot block the parent hook
|
|
5
|
+
* process (UserPromptSubmit, Stop) spawn a detached bun subprocess that
|
|
6
|
+
* re-enters the same handler script with a mode flag. This helper centralizes
|
|
7
|
+
* the spawn boilerplate: detach + unref + CLAUDECODE-scrub + debug/error logs.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* spawnDetachedInference(
|
|
11
|
+
* import.meta.filename, // re-invokes this script
|
|
12
|
+
* ["--sentiment", sessionId, msgB64], // mode flag + payload args
|
|
13
|
+
* "rating" // scope for logs
|
|
14
|
+
* );
|
|
15
|
+
*
|
|
16
|
+
* Payloads over a few KB should be passed via file path (write to tmp,
|
|
17
|
+
* pass path) rather than argv to avoid ARG_MAX limits (~256KB on macOS).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import { logDebug, logError } from "./log";
|
|
22
|
+
|
|
23
|
+
export function spawnDetachedInference(
|
|
24
|
+
scriptPath: string,
|
|
25
|
+
args: string[],
|
|
26
|
+
scope: string
|
|
27
|
+
): void {
|
|
28
|
+
try {
|
|
29
|
+
const child = spawn("bun", [scriptPath, ...args], {
|
|
30
|
+
detached: true,
|
|
31
|
+
stdio: "ignore",
|
|
32
|
+
env: { ...process.env, CLAUDECODE: undefined },
|
|
33
|
+
});
|
|
34
|
+
child.unref();
|
|
35
|
+
logDebug(scope, `detached inference spawned: ${args[0] ?? "no-mode"}`);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
logError(scope, err);
|
|
38
|
+
}
|
|
39
|
+
}
|