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.
Files changed (47) hide show
  1. package/README.md +2 -0
  2. package/assets/templates/hooks.antigravity.json +1 -1
  3. package/assets/templates/hooks.codex.json +11 -1
  4. package/assets/templates/hooks.copilot.json +11 -4
  5. package/assets/templates/hooks.cursor.json +8 -2
  6. package/assets/templates/settings.claude.json +55 -3
  7. package/package.json +1 -1
  8. package/src/cli/content.ts +23 -0
  9. package/src/cli/events.ts +47 -0
  10. package/src/cli/tree.ts +4 -0
  11. package/src/hooks/AttentionEvent.ts +22 -0
  12. package/src/hooks/LoadContext.ts +2 -2
  13. package/src/hooks/PostToolOrchestrator.ts +20 -0
  14. package/src/hooks/PreCompactPersist.ts +4 -2
  15. package/src/hooks/PreserveTranscripts.ts +13 -0
  16. package/src/hooks/SessionClose.ts +5 -0
  17. package/src/hooks/UserPromptOrchestrator.ts +9 -2
  18. package/src/hooks/lib/attention-events.ts +43 -0
  19. package/src/hooks/lib/auto-update.ts +24 -1
  20. package/src/hooks/lib/commitment-events.ts +20 -0
  21. package/src/hooks/lib/discord-events.ts +25 -0
  22. package/src/hooks/lib/entity-events.ts +16 -0
  23. package/src/hooks/lib/events.ts +252 -0
  24. package/src/hooks/lib/export.ts +1 -1
  25. package/src/hooks/lib/hook-timing.ts +33 -0
  26. package/src/hooks/lib/inference-events.ts +56 -0
  27. package/src/hooks/lib/inference.ts +17 -1
  28. package/src/hooks/lib/interaction.ts +74 -27
  29. package/src/hooks/lib/opencode-events.ts +59 -0
  30. package/src/hooks/lib/paths.ts +1 -0
  31. package/src/hooks/lib/post-tool.ts +66 -0
  32. package/src/hooks/lib/session-events.ts +211 -0
  33. package/src/hooks/lib/settings.ts +6 -0
  34. package/src/hooks/lib/stop.ts +27 -31
  35. package/src/hooks/lib/tool-events.ts +106 -0
  36. package/src/hooks/lib/transcript-preserve.ts +133 -0
  37. package/src/hooks/lib/turn-extras.ts +78 -0
  38. package/src/hooks/lib/waiting-on-user.ts +75 -0
  39. package/src/hooks/lib/work-tracking.ts +17 -3
  40. package/src/targets/opencode/plugin.ts +33 -1
  41. package/src/tools/agent/interaction.ts +2 -2
  42. package/src/tools/agent/project.ts +4 -0
  43. package/src/tools/control-room/snooze.ts +2 -0
  44. package/src/tools/control-room/writes.ts +2 -0
  45. package/src/tools/knowledge/lib.ts +5 -0
  46. package/src/hooks/LedgerCommit.ts +0 -34
  47. 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
- // Prune to last N
54
- const pruned = sessions.slice(-MAX_SESSIONS);
55
- writeFileSync(sessionsPath(), JSON.stringify(pruned, null, 2), "utf-8");
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
- _output: { title: string; output: string; metadata: unknown }
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/signals/interaction/ (features only, no
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
- }