portable-agent-layer 0.94.0 → 0.95.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 +2 -0
- package/assets/templates/hooks.antigravity.json +1 -1
- package/assets/templates/hooks.codex.json +11 -1
- package/assets/templates/hooks.copilot.json +11 -4
- package/assets/templates/hooks.cursor.json +8 -2
- package/assets/templates/settings.claude.json +55 -3
- package/package.json +1 -1
- package/src/cli/content.ts +23 -0
- package/src/cli/events.ts +47 -0
- package/src/cli/tree.ts +4 -0
- package/src/hooks/AttentionEvent.ts +22 -0
- package/src/hooks/LoadContext.ts +2 -2
- package/src/hooks/PostToolOrchestrator.ts +20 -0
- package/src/hooks/PreCompactPersist.ts +4 -2
- package/src/hooks/PreserveTranscripts.ts +13 -0
- package/src/hooks/SessionClose.ts +5 -0
- package/src/hooks/UserPromptOrchestrator.ts +9 -2
- package/src/hooks/lib/attention-events.ts +43 -0
- package/src/hooks/lib/auto-update.ts +24 -1
- package/src/hooks/lib/commitment-events.ts +20 -0
- package/src/hooks/lib/discord-events.ts +25 -0
- package/src/hooks/lib/entity-events.ts +16 -0
- package/src/hooks/lib/events.ts +252 -0
- package/src/hooks/lib/export.ts +1 -1
- package/src/hooks/lib/hook-timing.ts +33 -0
- package/src/hooks/lib/inference-events.ts +56 -0
- package/src/hooks/lib/inference.ts +17 -1
- package/src/hooks/lib/interaction.ts +74 -27
- package/src/hooks/lib/opencode-events.ts +59 -0
- package/src/hooks/lib/paths.ts +1 -0
- package/src/hooks/lib/post-tool.ts +66 -0
- package/src/hooks/lib/session-events.ts +211 -0
- package/src/hooks/lib/settings.ts +6 -0
- package/src/hooks/lib/stop.ts +27 -31
- package/src/hooks/lib/tool-events.ts +106 -0
- package/src/hooks/lib/transcript-preserve.ts +133 -0
- package/src/hooks/lib/turn-extras.ts +78 -0
- package/src/hooks/lib/waiting-on-user.ts +75 -0
- package/src/hooks/lib/work-tracking.ts +17 -3
- package/src/targets/opencode/plugin.ts +33 -1
- package/src/tools/agent/interaction.ts +2 -2
- package/src/tools/agent/project.ts +4 -0
- package/src/tools/control-room/snooze.ts +2 -0
- package/src/tools/control-room/writes.ts +2 -0
- package/src/tools/knowledge/lib.ts +5 -0
- package/src/hooks/LedgerCommit.ts +0 -34
- package/src/hooks/LedgerUnapplied.ts +0 -47
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A marker per session for the moment the agent started waiting on the user,
|
|
3
|
+
* taken by whatever the user does next so it can say how long they took.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import { ensureDir, paths } from "./paths";
|
|
9
|
+
|
|
10
|
+
const MAX_WAIT_SEC = 30 * 60;
|
|
11
|
+
|
|
12
|
+
type WaitKind = "permission" | "notified";
|
|
13
|
+
|
|
14
|
+
interface Waiting {
|
|
15
|
+
ts: string;
|
|
16
|
+
tool?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function waitingFile(kind: WaitKind, session: string): string {
|
|
20
|
+
const safe = session.replace(/[^\w-]/g, "_");
|
|
21
|
+
return resolve(
|
|
22
|
+
ensureDir(resolve(paths.state(), "waiting-on-user")),
|
|
23
|
+
`${kind}-${safe}.json`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function markWaiting(kind: WaitKind, session: string, waiting: Waiting): void {
|
|
28
|
+
writeFileSync(waitingFile(kind, session), JSON.stringify(waiting));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readWaiting(file: string): Waiting | null {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(file, "utf-8"));
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function secondsSince(ts: string, now: Date): number {
|
|
40
|
+
return Math.round((now.getTime() - Date.parse(ts)) / 100) / 10;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function takeWaiting(
|
|
44
|
+
kind: WaitKind,
|
|
45
|
+
session: string,
|
|
46
|
+
now: Date,
|
|
47
|
+
belongs: (waiting: Waiting) => boolean = () => true
|
|
48
|
+
): number | null {
|
|
49
|
+
const file = waitingFile(kind, session);
|
|
50
|
+
if (!existsSync(file)) return null;
|
|
51
|
+
const waiting = readWaiting(file);
|
|
52
|
+
if (waiting && !belongs(waiting)) return null;
|
|
53
|
+
rmSync(file, { force: true });
|
|
54
|
+
if (!waiting) return null;
|
|
55
|
+
const waited = secondsSince(waiting.ts, now);
|
|
56
|
+
return waited >= 0 && waited <= MAX_WAIT_SEC ? waited : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Seconds the user took to answer a prompt for this tool, once; null when none was open. */
|
|
60
|
+
export function permissionWaitSec(
|
|
61
|
+
session: string | null,
|
|
62
|
+
tool: string,
|
|
63
|
+
now: Date = new Date()
|
|
64
|
+
): number | null {
|
|
65
|
+
if (!session) return null;
|
|
66
|
+
return takeWaiting("permission", session, now, (waiting) => waiting.tool === tool);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Seconds from the last notification to the user's next prompt, once. */
|
|
70
|
+
export function backAfterNotificationSec(
|
|
71
|
+
session: string,
|
|
72
|
+
now: Date = new Date()
|
|
73
|
+
): number | null {
|
|
74
|
+
return takeWaiting("notified", session, now);
|
|
75
|
+
}
|
|
@@ -41,6 +41,17 @@ function readSessions(): SessionRecord[] {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function sessionArchiveFile(record: SessionRecord): string {
|
|
45
|
+
const month = Number.isNaN(Date.parse(record.ts)) ? "undated" : record.ts.slice(0, 7);
|
|
46
|
+
return resolve(ensureDir(resolve(paths.state(), "sessions-archive")), `${month}.jsonl`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function archiveSessions(records: SessionRecord[]): void {
|
|
50
|
+
for (const record of records) {
|
|
51
|
+
appendFileSync(sessionArchiveFile(record), `${JSON.stringify(record)}\n`, "utf-8");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
export function writeSession(record: SessionRecord): void {
|
|
45
56
|
const sessions = readSessions();
|
|
46
57
|
// Replace existing record with same sessionId, or append
|
|
@@ -50,9 +61,12 @@ export function writeSession(record: SessionRecord): void {
|
|
|
50
61
|
} else {
|
|
51
62
|
sessions.push(record);
|
|
52
63
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
archiveSessions(sessions.slice(0, -MAX_SESSIONS));
|
|
65
|
+
writeFileSync(
|
|
66
|
+
sessionsPath(),
|
|
67
|
+
JSON.stringify(sessions.slice(-MAX_SESSIONS), null, 2),
|
|
68
|
+
"utf-8"
|
|
69
|
+
);
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
/** Detect session completion status from last assistant message */
|
|
@@ -61,6 +61,16 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
61
61
|
|
|
62
62
|
// Load shared handlers
|
|
63
63
|
const { runStopHandlers } = await lib<typeof import("../../hooks/lib/stop")>("stop.ts");
|
|
64
|
+
const { logToolCall } =
|
|
65
|
+
await lib<typeof import("../../hooks/lib/tool-events")>("tool-events.ts");
|
|
66
|
+
const { logAttention } =
|
|
67
|
+
await lib<typeof import("../../hooks/lib/attention-events")>("attention-events.ts");
|
|
68
|
+
const {
|
|
69
|
+
logOpencodeCompaction,
|
|
70
|
+
logOpencodePermissionReply,
|
|
71
|
+
logOpencodeSessionCreated,
|
|
72
|
+
rememberAskedTool,
|
|
73
|
+
} = await lib<typeof import("../../hooks/lib/opencode-events")>("opencode-events.ts");
|
|
64
74
|
const { captureSessionName } = await lib<
|
|
65
75
|
typeof import("../../hooks/handlers/session-name")
|
|
66
76
|
>("../handlers/session-name.ts");
|
|
@@ -137,6 +147,13 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
137
147
|
autoUpdateOnClose();
|
|
138
148
|
}
|
|
139
149
|
|
|
150
|
+
if (event.type === "session.created")
|
|
151
|
+
logOpencodeSessionCreated(event.properties.info);
|
|
152
|
+
if (event.type === "session.compacted")
|
|
153
|
+
logOpencodeCompaction(event.properties.sessionID);
|
|
154
|
+
if (event.type === "permission.replied")
|
|
155
|
+
logOpencodePermissionReply(event.properties);
|
|
156
|
+
|
|
140
157
|
if (event.type === "session.created" || event.type === "session.updated") {
|
|
141
158
|
const { regenerateIfNeeded } =
|
|
142
159
|
await lib<typeof import("../../hooks/lib/claude-md")>("claude-md.ts");
|
|
@@ -237,10 +254,25 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
237
254
|
if (call) snapshotCall(call);
|
|
238
255
|
},
|
|
239
256
|
|
|
257
|
+
"permission.ask": async (input: { id: string; type: string; sessionID: string }) => {
|
|
258
|
+
rememberAskedTool(input);
|
|
259
|
+
logAttention({
|
|
260
|
+
hook_event_name: "PermissionRequest",
|
|
261
|
+
session_id: input.sessionID,
|
|
262
|
+
tool_name: input.type,
|
|
263
|
+
});
|
|
264
|
+
},
|
|
265
|
+
|
|
240
266
|
"tool.execute.after": async (
|
|
241
267
|
input: { tool: string; sessionID: string; callID: string; args: unknown },
|
|
242
|
-
|
|
268
|
+
output: { title: string; output: string; metadata: unknown }
|
|
243
269
|
) => {
|
|
270
|
+
logToolCall({
|
|
271
|
+
tool_name: input.tool,
|
|
272
|
+
tool_input: input.args,
|
|
273
|
+
session_id: input.sessionID,
|
|
274
|
+
tool_response: output?.output,
|
|
275
|
+
});
|
|
244
276
|
const call = ledgeredOpencodeCall(input.tool, input.callID, input.args);
|
|
245
277
|
if (!call) return;
|
|
246
278
|
|
|
@@ -12,8 +12,8 @@ import { group, leaf, runCommand, UsageError } from "../lib/command";
|
|
|
12
12
|
import { claimCheckLines, reportLines, summarize } from "../lib/interaction-report";
|
|
13
13
|
import { scriptArgs } from "../lib/script-args";
|
|
14
14
|
|
|
15
|
-
const DETAILS = `Reads the measured turns in memory/
|
|
16
|
-
text) and prints turns, channels, reactions, the hints sent, per agent the
|
|
15
|
+
const DETAILS = `Reads the measured turns from the event log in memory/events/ and the older
|
|
16
|
+
memory/signals/interaction/ files (features only, no text) and prints turns, channels, reactions, the hints sent, per agent the
|
|
17
17
|
turns, replies filed and hints sent, and for each label (short, long, fast,
|
|
18
18
|
skimming, friction) how the replies written while it was active compare with
|
|
19
19
|
the replies written while none was. Last, the replies that claimed a result
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
13
|
import { writeBinding } from "../../hooks/lib/bindings";
|
|
14
|
+
import { logCommitment } from "../../hooks/lib/commitment-events";
|
|
14
15
|
import { paths, toPath } from "../../hooks/lib/paths";
|
|
15
16
|
import {
|
|
16
17
|
defaultSlug,
|
|
@@ -454,6 +455,7 @@ function cmdCompleteIsc(name: string, id: number): undefined {
|
|
|
454
455
|
return;
|
|
455
456
|
}
|
|
456
457
|
applyIscMove(p, move);
|
|
458
|
+
logCommitment(name, id, "kept", "cli");
|
|
457
459
|
ok({ checked: true, id, archived: true });
|
|
458
460
|
}
|
|
459
461
|
|
|
@@ -479,6 +481,7 @@ function cmdReopenIsc(name: string, id: number): undefined {
|
|
|
479
481
|
return;
|
|
480
482
|
}
|
|
481
483
|
applyIscMove(p, move);
|
|
484
|
+
logCommitment(name, id, "reopened", "cli");
|
|
482
485
|
ok({ checked: false, id });
|
|
483
486
|
}
|
|
484
487
|
|
|
@@ -546,6 +549,7 @@ function cmdRetireIsc(name: string, id: number, by: number | null): undefined {
|
|
|
546
549
|
);
|
|
547
550
|
p.updated = now();
|
|
548
551
|
writeProject(p);
|
|
552
|
+
logCommitment(name, id, "dropped", "cli");
|
|
549
553
|
ok({ retired: true, id, supersededBy: by, archived: true });
|
|
550
554
|
}
|
|
551
555
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { resolve } from "node:path";
|
|
11
|
+
import { logCommitment } from "../../hooks/lib/commitment-events";
|
|
11
12
|
import { ensureDir, paths } from "../../hooks/lib/paths";
|
|
12
13
|
|
|
13
14
|
const DAY_MS = 86_400_000;
|
|
@@ -61,5 +62,6 @@ export function setSnooze(
|
|
|
61
62
|
if (days <= 0) delete live[key];
|
|
62
63
|
else live[key] = new Date(now.getTime() + days * DAY_MS).toISOString().slice(0, 10);
|
|
63
64
|
writeFileSync(snoozeFile(), `${JSON.stringify(live, null, 2)}\n`, "utf-8");
|
|
65
|
+
if (days > 0) logCommitment(project, id, "snoozed", "control-room", days);
|
|
64
66
|
return live[key] ?? null;
|
|
65
67
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* write surface over ~/.pal.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { logCommitment } from "../../hooks/lib/commitment-events";
|
|
9
10
|
import { readProject, writeProject } from "../../hooks/lib/projects";
|
|
10
11
|
import {
|
|
11
12
|
raw as rawSettings,
|
|
@@ -38,6 +39,7 @@ export function setIscStatus(project: string, id: number, close: boolean): Write
|
|
|
38
39
|
p.changelog = move.changelog;
|
|
39
40
|
p.updated = new Date().toISOString();
|
|
40
41
|
writeProject(p);
|
|
42
|
+
logCommitment(project, id, close ? "kept" : "reopened", "control-room");
|
|
41
43
|
return { ok: true, changed: true };
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
writeFileSync,
|
|
32
32
|
} from "node:fs";
|
|
33
33
|
import { resolve } from "node:path";
|
|
34
|
+
import { logEntityFirstSeen } from "../../hooks/lib/entity-events";
|
|
34
35
|
import { ensureDir, paths } from "../../hooks/lib/paths";
|
|
35
36
|
|
|
36
37
|
// --- Constants --------------------------------------------------------------
|
|
@@ -412,9 +413,13 @@ export function exists(domain: Domain, slug: string, rootDir?: string): boolean
|
|
|
412
413
|
|
|
413
414
|
export function save(entity: Entity, rootDir?: string): void {
|
|
414
415
|
const p = entityPath(entity.domain, entity.slug, rootDir);
|
|
416
|
+
const firstSeenInTheStore = !rootDir && !existsSync(p);
|
|
415
417
|
const tmp = `${p}.tmp`;
|
|
416
418
|
writeFileSync(tmp, serialize(entity), "utf-8");
|
|
417
419
|
renameSync(tmp, p);
|
|
420
|
+
if (firstSeenInTheStore) {
|
|
421
|
+
logEntityFirstSeen(entity.domain, entity.frontmatter.type, entity.frontmatter.title);
|
|
422
|
+
}
|
|
418
423
|
}
|
|
419
424
|
|
|
420
425
|
export function load(domain: Domain, slug: string, rootDir?: string): Entity | null {
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hook: PostToolUse — pairs the parked before-state with the result and writes
|
|
3
|
-
* the ledger entry.
|
|
4
|
-
*
|
|
5
|
-
* Only a call that actually landed reaches this event, so every entry written
|
|
6
|
-
* here is an applied one. A denied or failed call fires the snapshot half and
|
|
7
|
-
* never this one, leaving its snapshot unclaimed — which is why claiming also
|
|
8
|
-
* reaps the ones that were abandoned.
|
|
9
|
-
*
|
|
10
|
-
* Silent and fail-open, for the same reason as its other half.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { duplicatesCursorHooks } from "./lib/cursor-shadow";
|
|
14
|
-
import { enterHookWorkspace } from "./lib/hook-turn";
|
|
15
|
-
import { commitApplied, landedCalls } from "./lib/ledger-hook";
|
|
16
|
-
import { logDebug } from "./lib/log";
|
|
17
|
-
import { readStdinJSON } from "./lib/stdin";
|
|
18
|
-
|
|
19
|
-
if (duplicatesCursorHooks()) process.exit(0);
|
|
20
|
-
|
|
21
|
-
try {
|
|
22
|
-
const input = await readStdinJSON<Record<string, unknown>>();
|
|
23
|
-
if (!input) process.exit(0);
|
|
24
|
-
|
|
25
|
-
enterHookWorkspace(input);
|
|
26
|
-
for (const call of landedCalls(input)) {
|
|
27
|
-
const entry = commitApplied(call);
|
|
28
|
-
if (entry) {
|
|
29
|
-
logDebug("LedgerCommit", `recorded ${entry.id} ${entry.tool} ${entry.target}`);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
} catch {
|
|
33
|
-
process.exit(0);
|
|
34
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hook: PostToolUseFailure and PermissionDenied — records an edit that was
|
|
3
|
-
* attempted and did not land.
|
|
4
|
-
*
|
|
5
|
-
* A log that keeps only what succeeded cannot answer what was attempted, and
|
|
6
|
-
* that is usually the question being asked of it. So the two endings that are
|
|
7
|
-
* not success are recorded too, and kept apart: `failed` is the tool's own
|
|
8
|
-
* attempt breaking, `denied` is something refusing to let it run.
|
|
9
|
-
*
|
|
10
|
-
* One hook serves both events because they differ only in which key carries the
|
|
11
|
-
* reason — a difference `unappliedVerdictOf` owns, so registering this on a
|
|
12
|
-
* third such event later is a line in that table rather than a new file.
|
|
13
|
-
*
|
|
14
|
-
* Not everything that fails to land reaches here. A manual denial at the
|
|
15
|
-
* permission dialog, a `deny` rule, and a PreToolUse hook's own block all fire
|
|
16
|
-
* PreToolUse and nothing after it; a schema rejection fires no hook at all.
|
|
17
|
-
* Those attempts leave a snapshot no half ever claims, which is what the reaper
|
|
18
|
-
* in ledger.ts is for.
|
|
19
|
-
*
|
|
20
|
-
* Silent and fail-open, for the same reason as the other halves.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { duplicatesCursorHooks } from "./lib/cursor-shadow";
|
|
24
|
-
import { reapStalePending } from "./lib/ledger";
|
|
25
|
-
import { commitUnapplied, ledgeredCalls, unappliedVerdictOf } from "./lib/ledger-hook";
|
|
26
|
-
import { logDebug } from "./lib/log";
|
|
27
|
-
import { readStdinJSON } from "./lib/stdin";
|
|
28
|
-
|
|
29
|
-
if (duplicatesCursorHooks()) process.exit(0);
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
const input = await readStdinJSON<Record<string, unknown>>();
|
|
33
|
-
if (!input) process.exit(0);
|
|
34
|
-
|
|
35
|
-
const calls = ledgeredCalls(input);
|
|
36
|
-
const verdict = calls.length > 0 && unappliedVerdictOf(input);
|
|
37
|
-
if (!verdict) process.exit(0);
|
|
38
|
-
|
|
39
|
-
for (const call of calls) {
|
|
40
|
-
const entry = commitUnapplied(call, verdict);
|
|
41
|
-
logDebug("LedgerUnapplied", `recorded ${entry.id} ${entry.outcome} ${entry.target}`);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
reapStalePending();
|
|
45
|
-
} catch {
|
|
46
|
-
process.exit(0);
|
|
47
|
-
}
|