@usepowerplant/cli 0.4.16 → 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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
3
|
-
// before opening transcripts. Never persist prompts,
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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)
|
|
56
|
+
* (clear|resume|logout|prompt_input_exit|other); both land as `reason`.
|
|
39
57
|
*/
|
|
40
|
-
export function
|
|
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
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
67
|
-
if (
|
|
68
|
-
// The path is the user's homedir plus fixed segments
|
|
69
|
-
// hook input can steer where the journal lands.
|
|
70
|
-
const
|
|
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(
|
|
87
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
73
88
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
74
|
-
appendFileSync(
|
|
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
|