@usepowerplant/cli 0.4.15 → 0.4.17

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.
@@ -1,12 +1,21 @@
1
1
  #!/usr/bin/env node
2
- // Discovery only. The collector owns scope, transcript reads and uploads.
2
+ // Discovery and lifecycle events only. The collector owns scope, transcript
3
+ // reads and uploads. Never persist prompts, messages, or full input.
3
4
  import { Buffer } from "node:buffer";
4
5
  import { createHash, randomUUID } from "node:crypto";
5
- import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
+ import { appendFileSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
7
  import os from "node:os";
7
8
  import path from "node:path";
8
9
 
9
10
  const MAX_INPUT_BYTES = 128 * 1024;
11
+ // Codex's events, the lifecycle event each records, and the input field
12
+ // that holds its reason. The collector reads the journal in
13
+ // apps/syncservice/src/lifecycleJournal.ts.
14
+ const LIFECYCLE_EVENTS = new Map([
15
+ ["SessionStart", { event: "session-start", reasonField: "source" }],
16
+ ["Stop", { event: "turn-end" }],
17
+ ["SessionEnd", { event: "session-end", reasonField: "reason" }],
18
+ ]);
10
19
  let chunks = [];
11
20
  let bytes = 0;
12
21
  process.stdin.on("error", () => {});
@@ -17,43 +26,70 @@ process.stdin.on("data", (chunk) => {
17
26
  });
18
27
  process.stdin.on("end", () => {
19
28
  const dir = path.join(os.homedir(), ".config", "powerplant");
29
+ let input;
20
30
  try {
21
- if (bytes <= MAX_INPUT_BYTES) {
22
- const input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
23
- if (
24
- ["SessionStart", "SessionEnd", "Stop"].includes(input?.hook_event_name) &&
25
- typeof input.session_id === "string" &&
26
- /^[a-zA-Z0-9_-]{1,200}$/.test(input.session_id)
27
- ) {
28
- const record = {
29
- sessionId: input.session_id,
30
- transcriptPath:
31
- typeof input.transcript_path === "string" &&
32
- input.transcript_path.length <= 4096 &&
33
- path.isAbsolute(input.transcript_path)
34
- ? input.transcript_path
35
- : null,
36
- };
37
- const records = path.join(dir, "codex-transcripts");
31
+ if (bytes <= MAX_INPUT_BYTES) input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
32
+ } catch {
33
+ // Invalid input must not interrupt the agent.
34
+ }
35
+ const name = input?.hook_event_name;
36
+ const lifecycle = typeof name === "string" ? LIFECYCLE_EVENTS.get(name) : undefined;
37
+ const sessionId =
38
+ lifecycle !== undefined &&
39
+ typeof input.session_id === "string" &&
40
+ /^[a-zA-Z0-9_-]{1,200}$/.test(input.session_id)
41
+ ? input.session_id
42
+ : null;
43
+ if (sessionId !== null) {
44
+ try {
45
+ const record = {
46
+ sessionId,
47
+ transcriptPath:
48
+ typeof input.transcript_path === "string" &&
49
+ input.transcript_path.length <= 4096 &&
50
+ path.isAbsolute(input.transcript_path)
51
+ ? input.transcript_path
52
+ : null,
53
+ };
54
+ const records = path.join(dir, "codex-transcripts");
55
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
56
+ mkdirSync(records, { recursive: true, mode: 0o700 });
57
+ const file = path.join(records, `${createHash("sha256").update(sessionId).digest("hex")}.json`);
58
+ const temporary = `${file}.${randomUUID()}.tmp`;
59
+ try {
60
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
61
+ writeFileSync(temporary, JSON.stringify(record), { mode: 0o600, flag: "wx" });
38
62
  // eslint-disable-next-line security/detect-non-literal-fs-filename
39
- mkdirSync(records, { recursive: true, mode: 0o700 });
40
- const file = path.join(
41
- records,
42
- `${createHash("sha256").update(record.sessionId).digest("hex")}.json`
43
- );
44
- const temporary = `${file}.${randomUUID()}.tmp`;
45
- try {
46
- // eslint-disable-next-line security/detect-non-literal-fs-filename
47
- writeFileSync(temporary, JSON.stringify(record), { mode: 0o600, flag: "wx" });
48
- // eslint-disable-next-line security/detect-non-literal-fs-filename
49
- renameSync(temporary, file);
50
- } finally {
51
- rmSync(temporary, { force: true });
52
- }
63
+ renameSync(temporary, file);
64
+ } finally {
65
+ rmSync(temporary, { force: true });
53
66
  }
67
+ } catch {
68
+ // An unavailable disk must not interrupt the agent.
69
+ }
70
+ try {
71
+ const reason = lifecycle.reasonField === undefined ? undefined : input[lifecycle.reasonField];
72
+ const at = new Date().toISOString();
73
+ const entry = {
74
+ harness: "codex-local",
75
+ sessionId,
76
+ event: lifecycle.event,
77
+ nativeEvent: name,
78
+ ...(typeof reason === "string" && /^[a-zA-Z_-]{1,40}$/.test(reason) && { reason }),
79
+ at,
80
+ };
81
+ // Codex's own folder, one user-only file per UTC day; the collector
82
+ // deletes old ones.
83
+ const journal = path.join(dir, "lifecycle-journal", "codex-local");
84
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
85
+ mkdirSync(journal, { recursive: true, mode: 0o700 });
86
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
87
+ appendFileSync(path.join(journal, `${at.slice(0, 10)}.jsonl`), `${JSON.stringify(entry)}\n`, {
88
+ mode: 0o600,
89
+ });
90
+ } catch {
91
+ // The journal only adds evidence; transcript scans still find the session.
54
92
  }
55
- } catch {
56
- // Invalid input or unavailable disk must not interrupt the agent.
57
93
  }
58
94
  try {
59
95
  // Publish after the pointer; existing file scans also recover missed hooks.
@@ -1,13 +1,22 @@
1
1
  #!/usr/bin/env node
2
- // Local discovery only. The collector validates paths and capture policy
3
- // before opening transcripts. Never persist prompts, email, or full input.
2
+ // Local discovery and lifecycle events only. The collector validates paths
3
+ // and capture policy before opening transcripts. Never persist prompts,
4
+ // email, or full input.
4
5
  import { createHash, randomUUID } from "node:crypto";
5
6
  import { Buffer } from "node:buffer";
6
- import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
7
+ import { appendFileSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
7
8
  import os from "node:os";
8
9
  import path from "node:path";
9
10
 
10
11
  const MAX_INPUT_BYTES = 128 * 1024;
12
+ // Cursor's events, the lifecycle event each records, and the input field
13
+ // that holds its reason. The collector reads the journal in
14
+ // apps/syncservice/src/lifecycleJournal.ts.
15
+ const LIFECYCLE_EVENTS = new Map([
16
+ ["sessionStart", { event: "session-start" }],
17
+ ["stop", { event: "turn-end", reasonField: "status" }],
18
+ ["sessionEnd", { event: "session-end", reasonField: "reason" }],
19
+ ]);
11
20
  let chunks = [];
12
21
  let bytes = 0;
13
22
  process.stdin.on("error", () => {});
@@ -18,54 +27,81 @@ process.stdin.on("data", (chunk) => {
18
27
  });
19
28
  process.stdin.on("end", () => {
20
29
  const dir = path.join(os.homedir(), ".config", "powerplant");
30
+ let input;
21
31
  try {
22
- if (bytes <= MAX_INPUT_BYTES) {
23
- const input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
24
- if (
25
- ["sessionStart", "sessionEnd", "stop"].includes(input?.hook_event_name) &&
26
- typeof input.conversation_id === "string" &&
27
- /^[a-zA-Z0-9_-]{1,200}$/.test(input.conversation_id)
28
- ) {
29
- const roots =
30
- Array.isArray(input.workspace_roots) &&
31
- input.workspace_roots.length <= 8 &&
32
- input.workspace_roots.every(
33
- (p) => typeof p === "string" && p.length <= 4096 && path.isAbsolute(p)
34
- )
35
- ? input.workspace_roots
36
- : [];
37
- const record = {
38
- sessionId: input.conversation_id,
39
- transcriptPath:
40
- typeof input.transcript_path === "string" &&
41
- input.transcript_path.length <= 4096 &&
42
- path.isAbsolute(input.transcript_path)
43
- ? input.transcript_path
44
- : null,
45
- workspaceRoots: roots,
46
- ...(typeof input.model === "string" &&
47
- input.model.length <= 256 && { model: input.model }),
48
- };
49
- const records = path.join(dir, "cursor-transcripts");
32
+ if (bytes <= MAX_INPUT_BYTES) input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
33
+ } catch {
34
+ // Invalid input must never stop the provider.
35
+ }
36
+ const name = input?.hook_event_name;
37
+ const lifecycle = typeof name === "string" ? LIFECYCLE_EVENTS.get(name) : undefined;
38
+ const sessionId =
39
+ lifecycle !== undefined &&
40
+ typeof input.conversation_id === "string" &&
41
+ /^[a-zA-Z0-9_-]{1,200}$/.test(input.conversation_id)
42
+ ? input.conversation_id
43
+ : null;
44
+ if (sessionId !== null) {
45
+ try {
46
+ const roots =
47
+ Array.isArray(input.workspace_roots) &&
48
+ input.workspace_roots.length <= 8 &&
49
+ input.workspace_roots.every(
50
+ (p) => typeof p === "string" && p.length <= 4096 && path.isAbsolute(p)
51
+ )
52
+ ? input.workspace_roots
53
+ : [];
54
+ const record = {
55
+ sessionId,
56
+ transcriptPath:
57
+ typeof input.transcript_path === "string" &&
58
+ input.transcript_path.length <= 4096 &&
59
+ path.isAbsolute(input.transcript_path)
60
+ ? input.transcript_path
61
+ : null,
62
+ workspaceRoots: roots,
63
+ ...(typeof input.model === "string" &&
64
+ input.model.length <= 256 && { model: input.model }),
65
+ };
66
+ const records = path.join(dir, "cursor-transcripts");
67
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
68
+ mkdirSync(records, { recursive: true, mode: 0o700 });
69
+ const file = path.join(records, `${createHash("sha256").update(sessionId).digest("hex")}.json`);
70
+ const temporary = `${file}.${randomUUID()}.tmp`;
71
+ try {
72
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
73
+ writeFileSync(temporary, JSON.stringify(record), { mode: 0o600, flag: "wx" });
50
74
  // eslint-disable-next-line security/detect-non-literal-fs-filename
51
- mkdirSync(records, { recursive: true, mode: 0o700 });
52
- const file = path.join(
53
- records,
54
- `${createHash("sha256").update(record.sessionId).digest("hex")}.json`
55
- );
56
- const temporary = `${file}.${randomUUID()}.tmp`;
57
- try {
58
- // eslint-disable-next-line security/detect-non-literal-fs-filename
59
- writeFileSync(temporary, JSON.stringify(record), { mode: 0o600, flag: "wx" });
60
- // eslint-disable-next-line security/detect-non-literal-fs-filename
61
- renameSync(temporary, file);
62
- } finally {
63
- rmSync(temporary, { force: true });
64
- }
75
+ renameSync(temporary, file);
76
+ } finally {
77
+ rmSync(temporary, { force: true });
65
78
  }
79
+ } catch {
80
+ // Discovery failure must never stop the provider. Existing capture remains.
81
+ }
82
+ try {
83
+ const reason = lifecycle.reasonField === undefined ? undefined : input[lifecycle.reasonField];
84
+ const at = new Date().toISOString();
85
+ const entry = {
86
+ harness: "cursor-local",
87
+ sessionId,
88
+ event: lifecycle.event,
89
+ nativeEvent: name,
90
+ ...(typeof reason === "string" && /^[a-zA-Z_-]{1,40}$/.test(reason) && { reason }),
91
+ at,
92
+ };
93
+ // Cursor's own folder, one user-only file per UTC day; the collector
94
+ // deletes old ones.
95
+ const journal = path.join(dir, "lifecycle-journal", "cursor-local");
96
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
97
+ mkdirSync(journal, { recursive: true, mode: 0o700 });
98
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
99
+ appendFileSync(path.join(journal, `${at.slice(0, 10)}.jsonl`), `${JSON.stringify(entry)}\n`, {
100
+ mode: 0o600,
101
+ });
102
+ } catch {
103
+ // The journal only adds evidence; existing capture remains.
66
104
  }
67
- } catch {
68
- // Discovery failure must never stop the provider. Existing capture remains.
69
105
  }
70
106
  try {
71
107
  // Publish after the pointer so the watcher sees a complete discovery record.
@@ -1,19 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Powerplant session lifecycle — SessionStart / SessionEnd hook.
3
+ * Powerplant session lifecycle: the Claude Code SessionStart, Stop, and
4
+ * SessionEnd hook.
4
5
  *
5
6
  * Claude Code fires these at real session boundaries (start, resume, clear,
6
- * quit). This hook appends one line per event to a local journal
7
- * (~/.config/powerplant/session-lifecycle.jsonl) so the collector's scan can
8
- * mark a session "completed" the moment its SessionEnd arrives, with the
9
- * observed end time — instead of waiting out the transcript-quiet grace
10
- * window. The `at` stamp is when THIS process observed the hook fire; it is
11
- * never a claimed or reconstructed time.
7
+ * quit) and when a turn ends. This hook appends one line per event to the
8
+ * lifecycle journal, one file per UTC day in
9
+ * ~/.config/powerplant/lifecycle-journal/claude-local/. Each line names the shared
10
+ * lifecycle event and keeps Claude Code's own event name as detail. The
11
+ * collector's scan marks a session "completed" the moment its SessionEnd
12
+ * arrives, with the observed end time, instead of waiting out the
13
+ * transcript-quiet grace window. The `at` stamp is when THIS process
14
+ * observed the hook fire; it is never a claimed or reconstructed time.
15
+ *
16
+ * Only the session id, the event names, and Claude Code's source or reason
17
+ * token are kept. Stop input carries the last assistant message; it is
18
+ * never written.
12
19
  *
13
20
  * This file is the CANONICAL SOURCE; the collector's watch service copies it
14
21
  * to ~/.config/powerplant/hooks/ and wires it into the user's
15
22
  * ~/.claude/settings.json (hookInstall.ts), so every repo's Claude sessions
16
- * report — not just this checkout's. This hook NEVER blocks or breaks a
23
+ * report, not just this checkout's. This hook NEVER blocks or breaks a
17
24
  * session: every failure path exits 0 with no output.
18
25
  *
19
26
  * Logic lives in the exported functions so the collector workspace can unit
@@ -26,36 +33,44 @@ import os from "node:os";
26
33
  import path from "node:path";
27
34
  import { pathToFileURL } from "node:url";
28
35
 
29
- /** The collector's watcher and scan read this same path (lifecycleJournal.ts). */
30
- export function journalPath(home) {
31
- return path.join(home, ".config", "powerplant", "session-lifecycle.jsonl");
36
+ /** Claude Code's events, the lifecycle event each records, and the input
37
+ * field that holds its reason. */
38
+ const LIFECYCLE_EVENTS = new Map([
39
+ ["SessionStart", { event: "session-start", reasonField: "source" }],
40
+ ["Stop", { event: "turn-end" }],
41
+ ["SessionEnd", { event: "session-end", reasonField: "reason" }],
42
+ ]);
43
+ const SESSION_ID = /^[a-zA-Z0-9_-]{1,200}$/;
44
+ const REASON = /^[a-zA-Z_-]{1,40}$/;
45
+
46
+ /** Claude Code's folder in the journal; the collector reads it
47
+ * (lifecycleJournalHarnessDir in lifecycleJournal.ts). */
48
+ export function journalDir(home) {
49
+ return path.join(home, ".config", "powerplant", "lifecycle-journal", "claude-local");
32
50
  }
33
51
 
34
52
  /**
35
- * One journal line from the hook's stdin JSON; null when the input isn't a
53
+ * One journal entry from the hook's stdin JSON; null when the input isn't a
36
54
  * lifecycle event we can attribute to a session. SessionStart carries a
37
55
  * `source` (startup|resume|clear|compact|fork), SessionEnd a `reason`
38
- * (clear|resume|logout|prompt_input_exit|other) — both land as `reason`.
56
+ * (clear|resume|logout|prompt_input_exit|other); both land as `reason`.
39
57
  */
40
- export function journalLineOf(inputText, at) {
58
+ export function journalEntryOf(inputText, at) {
41
59
  try {
42
60
  const input = JSON.parse(inputText);
61
+ const nativeEvent = input?.hook_event_name;
62
+ const mapping = typeof nativeEvent === "string" ? LIFECYCLE_EVENTS.get(nativeEvent) : undefined;
43
63
  const sessionId = input?.session_id;
44
- if (typeof sessionId !== "string" || sessionId === "") return null;
45
- const event =
46
- input?.hook_event_name === "SessionStart"
47
- ? "start"
48
- : input?.hook_event_name === "SessionEnd"
49
- ? "end"
50
- : null;
51
- if (event === null) return null;
52
- const reason = event === "start" ? input?.source : input?.reason;
53
- return JSON.stringify({
64
+ if (mapping === undefined || typeof sessionId !== "string" || !SESSION_ID.test(sessionId)) return null;
65
+ const reason = mapping.reasonField === undefined ? undefined : input[mapping.reasonField];
66
+ return {
67
+ harness: "claude-local",
54
68
  sessionId,
55
- event,
56
- ...(typeof reason === "string" && reason !== "" && { reason }),
69
+ event: mapping.event,
70
+ nativeEvent,
71
+ ...(typeof reason === "string" && REASON.test(reason) && { reason }),
57
72
  at,
58
- });
73
+ };
59
74
  } catch {
60
75
  return null;
61
76
  }
@@ -63,15 +78,17 @@ export function journalLineOf(inputText, at) {
63
78
 
64
79
  function main() {
65
80
  try {
66
- const line = journalLineOf(readFileSync(0, "utf8"), new Date().toISOString());
67
- if (line === null) return;
68
- // The path is the user's homedir plus fixed segments — nothing from the
69
- // hook input can steer where the journal lands.
70
- const file = journalPath(os.homedir());
81
+ const entry = journalEntryOf(readFileSync(0, "utf8"), new Date().toISOString());
82
+ if (entry === null) return;
83
+ // The path is the user's homedir plus fixed segments and the date;
84
+ // nothing from the hook input can steer where the journal lands.
85
+ const dir = journalDir(os.homedir());
71
86
  // eslint-disable-next-line security/detect-non-literal-fs-filename
72
- mkdirSync(path.dirname(file), { recursive: true });
87
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
73
88
  // eslint-disable-next-line security/detect-non-literal-fs-filename
74
- appendFileSync(file, `${line}\n`);
89
+ appendFileSync(path.join(dir, `${entry.at.slice(0, 10)}.jsonl`), `${JSON.stringify(entry)}\n`, {
90
+ mode: 0o600,
91
+ });
75
92
  } catch {
76
93
  // never surface an error into someone's session
77
94
  }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usepowerplant/cli",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -29,7 +29,7 @@
29
29
  "devDependencies": {
30
30
  "@powerplant/collector": "workspace:*",
31
31
  "@powerplant/core": "workspace:*",
32
- "@types/node": "^26.5.1",
32
+ "@types/node": "^26.6.2",
33
33
  "esbuild": "^0.28.2",
34
34
  "typescript": "^5.9.3",
35
35
  "vitest": "^4.1.11",