portable-agent-layer 0.94.0 → 0.95.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.
Files changed (50) 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/tools/lib/session-usage.ts +7 -3
  47. package/src/tools/lib/transcript-usage.ts +14 -0
  48. package/src/tools/lib/usage-buckets.ts +15 -8
  49. package/src/hooks/LedgerCommit.ts +0 -34
  50. 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 {
@@ -10,7 +10,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { resolve } from "node:path";
12
12
  import { costOfUsage } from "../../hooks/lib/models";
13
- import { cacheWritesOf, type TranscriptUsage } from "./transcript-usage";
13
+ import { cacheWritesOf, finalPerMessage, type TranscriptUsage } from "./transcript-usage";
14
14
 
15
15
  export interface SessionUsage {
16
16
  input: number;
@@ -94,7 +94,7 @@ interface TranscriptLine {
94
94
  type?: string;
95
95
  timestamp?: string;
96
96
  sessionId?: string;
97
- message?: { model?: string; usage?: TranscriptUsage };
97
+ message?: { id?: string; model?: string; usage?: TranscriptUsage };
98
98
  }
99
99
 
100
100
  function emptyUsage(): SessionUsage {
@@ -113,6 +113,7 @@ function emptyUsage(): SessionUsage {
113
113
 
114
114
  export function accumulateUsage(transcript: string, sessionId: string): SessionUsage {
115
115
  const total = emptyUsage();
116
+ const assistantTurns: TranscriptLine[] = [];
116
117
  let firstTs = "";
117
118
  let lastTs = "";
118
119
 
@@ -133,7 +134,10 @@ export function accumulateUsage(transcript: string, sessionId: string): SessionU
133
134
  lastTs = entry.timestamp;
134
135
  }
135
136
 
136
- if (entry.type !== "assistant") continue;
137
+ if (entry.type === "assistant") assistantTurns.push(entry);
138
+ }
139
+
140
+ for (const entry of finalPerMessage(assistantTurns, (e) => e.message?.id)) {
137
141
  const usage = entry.message?.usage;
138
142
  const model = entry.message?.model;
139
143
  if (!usage || !model) continue;
@@ -18,6 +18,20 @@ export interface TranscriptUsage {
18
18
  };
19
19
  }
20
20
 
21
+ /**
22
+ * Claude Code writes one transcript line per content block of a message, each
23
+ * repeating the message's usage — and an early line can carry the usage while
24
+ * the message was still streaming. A message is billed once, at its last line.
25
+ */
26
+ export function finalPerMessage<T>(
27
+ calls: Iterable<T>,
28
+ messageIdOf: (call: T) => string | undefined
29
+ ): T[] {
30
+ const byMessage = new Map<unknown, T>();
31
+ for (const call of calls) byMessage.set(messageIdOf(call) ?? call, call);
32
+ return [...byMessage.values()];
33
+ }
34
+
21
35
  export interface CacheWrites {
22
36
  cacheWrite5m: number;
23
37
  cacheWrite1h: number;
@@ -7,7 +7,7 @@
7
7
  import { existsSync, readdirSync, readFileSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
9
  import { costOfUsage } from "../../hooks/lib/models";
10
- import { cacheWritesOf, type TranscriptUsage } from "./transcript-usage";
10
+ import { cacheWritesOf, finalPerMessage, type TranscriptUsage } from "./transcript-usage";
11
11
 
12
12
  export interface Bucket {
13
13
  input: number;
@@ -180,7 +180,7 @@ export function usageTally(projectFilter?: string, now: Date = new Date()): Usag
180
180
  interface TranscriptLine {
181
181
  type?: string;
182
182
  timestamp?: string;
183
- message?: { model?: string; usage?: TranscriptUsage };
183
+ message?: { id?: string; model?: string; usage?: TranscriptUsage };
184
184
  }
185
185
 
186
186
  /**
@@ -209,6 +209,7 @@ function transcriptsIn(projPath: string): string[] {
209
209
  }
210
210
 
211
211
  interface PricedCall {
212
+ messageId?: string;
212
213
  ts: string;
213
214
  model: string;
214
215
  tokens: Tokens;
@@ -230,6 +231,7 @@ function pricedCallOf(line: string): PricedCall | null {
230
231
  if (!usage || !model || !ts) return null;
231
232
 
232
233
  return {
234
+ messageId: entry.message?.id,
233
235
  ts,
234
236
  model,
235
237
  tokens: {
@@ -248,6 +250,7 @@ export function readClaudeCode(
248
250
  ): AgentUsage {
249
251
  const tally = usageTally(projectFilter, now);
250
252
  if (!existsSync(claudeDir)) return tally.usage;
253
+ const calls: (PricedCall & { project: string })[] = [];
251
254
 
252
255
  const projectDirs = readdirSync(claudeDir, { withFileTypes: true })
253
256
  .filter((entry) => entry.isDirectory())
@@ -269,16 +272,20 @@ export function readClaudeCode(
269
272
  // Cheap reject before the parse: most lines are not model turns.
270
273
  if (!line.includes('"usage"')) continue;
271
274
  const call = pricedCallOf(line);
272
- if (!call) continue;
273
- tally.record({
274
- ...call,
275
- project: projName,
276
- cost: costOfUsage(call.model, call.tokens),
277
- });
275
+ if (call) calls.push({ ...call, project: projName });
278
276
  }
279
277
  }
280
278
  }
281
279
 
280
+ for (const call of finalPerMessage(calls, (c) => c.messageId)) {
281
+ tally.record({
282
+ ts: call.ts,
283
+ model: call.model,
284
+ tokens: call.tokens,
285
+ project: call.project,
286
+ cost: costOfUsage(call.model, call.tokens),
287
+ });
288
+ }
282
289
  return tally.usage;
283
290
  }
284
291
 
@@ -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
- }