coolhand-cli 0.12.0 → 0.13.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 +5 -1
- package/dist/api/help-center-client.d.ts.map +1 -1
- package/dist/api/help-center-client.js +5 -4
- package/dist/api/help-center-client.js.map +1 -1
- package/dist/auto-update/index.d.ts +61 -0
- package/dist/auto-update/index.d.ts.map +1 -0
- package/dist/auto-update/index.js +355 -0
- package/dist/auto-update/index.js.map +1 -0
- package/dist/auto-update/select.d.ts +28 -0
- package/dist/auto-update/select.d.ts.map +1 -0
- package/dist/auto-update/select.js +45 -0
- package/dist/auto-update/select.js.map +1 -0
- package/dist/auto-update/state.d.ts +25 -0
- package/dist/auto-update/state.d.ts.map +1 -0
- package/dist/auto-update/state.js +81 -0
- package/dist/auto-update/state.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +97 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/capture-turn.d.ts +18 -0
- package/dist/commands/capture-turn.d.ts.map +1 -0
- package/dist/commands/capture-turn.js +152 -0
- package/dist/commands/capture-turn.js.map +1 -0
- package/dist/commands/flush-pending.d.ts.map +1 -1
- package/dist/commands/flush-pending.js +1 -30
- package/dist/commands/flush-pending.js.map +1 -1
- package/dist/commands/update.d.ts +9 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +63 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +25 -7
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/hooks/adapters/claude.d.ts +12 -0
- package/dist/hooks/adapters/claude.d.ts.map +1 -0
- package/dist/hooks/adapters/claude.js +98 -0
- package/dist/hooks/adapters/claude.js.map +1 -0
- package/dist/hooks/adapters/copilot.d.ts +15 -0
- package/dist/hooks/adapters/copilot.d.ts.map +1 -0
- package/dist/hooks/adapters/copilot.js +96 -0
- package/dist/hooks/adapters/copilot.js.map +1 -0
- package/dist/hooks/adapters/index.d.ts +5 -0
- package/dist/hooks/adapters/index.d.ts.map +1 -0
- package/dist/hooks/adapters/index.js +8 -0
- package/dist/hooks/adapters/index.js.map +1 -0
- package/dist/hooks/adapters/shared.d.ts +25 -0
- package/dist/hooks/adapters/shared.d.ts.map +1 -0
- package/dist/hooks/adapters/shared.js +37 -0
- package/dist/hooks/adapters/shared.js.map +1 -0
- package/dist/hooks/disabled.d.ts +3 -0
- package/dist/hooks/disabled.d.ts.map +1 -0
- package/dist/hooks/disabled.js +5 -0
- package/dist/hooks/disabled.js.map +1 -0
- package/dist/hooks/hook-log.d.ts +7 -0
- package/dist/hooks/hook-log.d.ts.map +1 -0
- package/dist/hooks/hook-log.js +34 -0
- package/dist/hooks/hook-log.js.map +1 -0
- package/dist/hooks/identity.d.ts +21 -0
- package/dist/hooks/identity.d.ts.map +1 -0
- package/dist/hooks/identity.js +121 -0
- package/dist/hooks/identity.js.map +1 -0
- package/dist/hooks/log-turn.d.ts +23 -0
- package/dist/hooks/log-turn.d.ts.map +1 -0
- package/dist/hooks/log-turn.js +48 -0
- package/dist/hooks/log-turn.js.map +1 -0
- package/dist/hooks/send-turn.d.ts +32 -0
- package/dist/hooks/send-turn.d.ts.map +1 -0
- package/dist/hooks/send-turn.js +305 -0
- package/dist/hooks/send-turn.js.map +1 -0
- package/dist/hooks/silence.d.ts +7 -0
- package/dist/hooks/silence.d.ts.map +1 -0
- package/dist/hooks/silence.js +22 -0
- package/dist/hooks/silence.js.map +1 -0
- package/dist/hooks/stdin.d.ts +11 -0
- package/dist/hooks/stdin.d.ts.map +1 -0
- package/dist/hooks/stdin.js +51 -0
- package/dist/hooks/stdin.js.map +1 -0
- package/dist/hooks/turn-state.d.ts +40 -0
- package/dist/hooks/turn-state.d.ts.map +1 -0
- package/dist/hooks/turn-state.js +97 -0
- package/dist/hooks/turn-state.js.map +1 -0
- package/dist/hooks/types.d.ts +74 -0
- package/dist/hooks/types.d.ts.map +1 -0
- package/dist/hooks/types.js +3 -0
- package/dist/hooks/types.js.map +1 -0
- package/dist/pending-store.d.ts +22 -2
- package/dist/pending-store.d.ts.map +1 -1
- package/dist/pending-store.js +25 -3
- package/dist/pending-store.js.map +1 -1
- package/dist/pending-upload.d.ts +12 -0
- package/dist/pending-upload.d.ts.map +1 -0
- package/dist/pending-upload.js +51 -0
- package/dist/pending-upload.js.map +1 -0
- package/dist/sessions/blocks.d.ts +51 -0
- package/dist/sessions/blocks.d.ts.map +1 -0
- package/dist/sessions/blocks.js +193 -0
- package/dist/sessions/blocks.js.map +1 -0
- package/dist/sessions/claude-scanner.d.ts +60 -1
- package/dist/sessions/claude-scanner.d.ts.map +1 -1
- package/dist/sessions/claude-scanner.js +277 -1
- package/dist/sessions/claude-scanner.js.map +1 -1
- package/dist/sessions/copilot-scanner.d.ts +27 -0
- package/dist/sessions/copilot-scanner.d.ts.map +1 -0
- package/dist/sessions/copilot-scanner.js +171 -0
- package/dist/sessions/copilot-scanner.js.map +1 -0
- package/dist/sessions/redact-secrets.d.ts.map +1 -1
- package/dist/sessions/redact-secrets.js +43 -10
- package/dist/sessions/redact-secrets.js.map +1 -1
- package/dist/sessions/turn-envelope.d.ts +62 -0
- package/dist/sessions/turn-envelope.d.ts.map +1 -0
- package/dist/sessions/turn-envelope.js +182 -0
- package/dist/sessions/turn-envelope.js.map +1 -0
- package/dist/sessions/usage.d.ts +23 -0
- package/dist/sessions/usage.d.ts.map +1 -0
- package/dist/sessions/usage.js +56 -0
- package/dist/sessions/usage.js.map +1 -0
- package/dist/types.d.ts +21 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"log-turn.js","sourceRoot":"","sources":["../../src/hooks/log-turn.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAwF,MAAM,eAAe,CAAC;AAClI,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAa7C,gEAAgE;AAChE,MAAM,YAAY,GAAG,CAAC,CAAC;AACvB,MAAM,iBAAiB,GAAG,GAAG,CAAC;AAE9B,SAAS,WAAW,CAAC,OAAgB;IACnC,MAAM,IAAI,GAAG,OAAO,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,SAAS,IAAI,OAAO,CAAC,CAAC,CAAC,MAAM,CAAE,OAAgC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAClJ,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,iBAAiB,CAAC,CAAC;AAC1C,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,QAAkB,EAAE,QAAiB,EAAE,IAAoB;IACvF,IAAI,OAAO,GAAG,KAAK,CAAC;IACpB,MAAM,IAAI,GAAG,GAAwC,EAAE;QACrD,OAAO,GAAG,IAAI,CAAC;QACf,OAAO,QAAQ,CAAC,UAAU,CAAC,QAA4B,EAAE,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC;IACnH,CAAC,CAAC;IAEF,IAAI,MAAkC,CAAC;IACvC,IAAI,CAAC;QACH,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,WAAW,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IAC7E,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,IAAI,CAAC,QAAQ,IAAI,OAAO,EAAE,CAAC;YAC9B,OAAO,IAAI,CAAC;QACd,CAAC;QACD,MAAM,YAAY,CAAC,wDAAyD,GAAa,CAAC,OAAO,iCAAiC,CAAC,CAAC;QACpI,IAAI,CAAC;YACH,MAAM,GAAG,MAAM,IAAI,EAAE,CAAC;QACxB,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACzH,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxB,MAAM,YAAY,CAAC,6DAA6D,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAC1G,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type DeclaredContext } from 'coolhand-node';
|
|
2
|
+
export interface SendTurnOptions {
|
|
3
|
+
apiKey: string;
|
|
4
|
+
baseUrl?: string;
|
|
5
|
+
collector: string;
|
|
6
|
+
metadata: Record<string, unknown>;
|
|
7
|
+
/** Per-request cap in ms. Defaults to 5 seconds; tests shorten it. */
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
/** The workload to declare for this log (workload only; no template). Kept with a spooled turn so its replay declares it too. */
|
|
10
|
+
declared?: DeclaredContext;
|
|
11
|
+
/** Which session and turn this is, so older spooled turns of the same session can be discarded. */
|
|
12
|
+
session?: string;
|
|
13
|
+
turn?: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Re-send turns spooled by earlier failures, NEWEST first: after a key rotation the old records carry
|
|
17
|
+
* a dead key and fail forever, and the turns that matter are the recent ones behind them. The server
|
|
18
|
+
* does not dedup, so a replay that lands twice is a duplicate log: each record is claimed atomically
|
|
19
|
+
* (overlapping hook runs can't both send it) and dropped after MAX_REPLAY_ATTEMPTS failures. One
|
|
20
|
+
* record the server keeps refusing doesn't block the rest; the run stops after several failures in a
|
|
21
|
+
* row or once its time budget is spent. The spool is pruned first so stale records age out.
|
|
22
|
+
*/
|
|
23
|
+
export declare function replaySpooledTurns(timeoutMs?: number): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Send one turn envelope to Coolhand with the supplied public key. On any failure (network, timeout,
|
|
26
|
+
* server refusal) the turn is spooled to `~/.coolhand/pending/` carrying the key and base url, so it
|
|
27
|
+
* is replayed (see {@link replaySpooledTurns}, which the caller runs after a successful send), without a login.
|
|
28
|
+
* Returns `'sent'`, `'spooled'`, or `'failed'` when the send failed and the turn could not be spooled
|
|
29
|
+
* either (unwritable pending dir, disk full). Throws only for an unusable `baseUrl`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function sendTurn(envelope: unknown, opts: SendTurnOptions): Promise<'sent' | 'spooled' | 'failed'>;
|
|
32
|
+
//# sourceMappingURL=send-turn.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"send-turn.d.ts","sourceRoot":"","sources":["../../src/hooks/send-turn.ts"],"names":[],"mappings":"AAKA,OAAO,EAAY,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAmC/D,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iIAAiI;IACjI,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,mGAAmG;IACnG,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAsID;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CAAC,SAAS,GAAE,MAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAqF3F;AASD;;;;;;GAMG;AACH,wBAAsB,QAAQ,CAAC,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,GAAG,QAAQ,CAAC,CA6C/G"}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { atomicWriteFile } from '../config.js';
|
|
5
|
+
import { logHookEvent } from './hook-log.js';
|
|
6
|
+
import { Coolhand } from 'coolhand-node';
|
|
7
|
+
import { logTurn } from './log-turn.js';
|
|
8
|
+
import { CliError } from '../errors.js';
|
|
9
|
+
import { DEFAULT_BASE_URL } from '../types.js';
|
|
10
|
+
import { listTurnFiles, pendingDir, readPendingRecord, removePendingRecord, savePendingRecord, TURN_RECORD_COMMAND, } from '../pending-store.js';
|
|
11
|
+
import { uploadRecord } from '../pending-upload.js';
|
|
12
|
+
/** A hook runs on the agent's clock; never hold it longer than this. */
|
|
13
|
+
const SEND_TIMEOUT_MS = 5000;
|
|
14
|
+
/** Spooled turns replayed per successful send, so a long outage drains over a few turns. */
|
|
15
|
+
const RETRY_BATCH = 10;
|
|
16
|
+
/** Give up on a replay run after this many failures in a row (the network is probably down). */
|
|
17
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
18
|
+
/** A spooled turn older than this is dropped, as is anything beyond the newest MAX_SPOOLED. */
|
|
19
|
+
const SPOOL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
|
+
const MAX_SPOOLED = 200;
|
|
21
|
+
/** Total size cap on the spool: each turn now carries the conversation so far and can be several hundred KB. */
|
|
22
|
+
const MAX_SPOOL_BYTES = 64 * 1024 * 1024;
|
|
23
|
+
/** Total wall-clock budget for one replay run, so slow-but-answering servers can't hold the hook open. */
|
|
24
|
+
const REPLAY_BUDGET_MS = 15000;
|
|
25
|
+
/** Don't start a replay upload with less budget than this left. */
|
|
26
|
+
const MIN_UPLOAD_MS = 1000;
|
|
27
|
+
/** A spooled turn is dropped after this many failed replays. */
|
|
28
|
+
const MAX_REPLAY_ATTEMPTS = 3;
|
|
29
|
+
/** A record being replayed is renamed to `<name>.json.claim`; a claim this old belongs to a dead process. */
|
|
30
|
+
const CLAIM_SUFFIX = '.claim';
|
|
31
|
+
const STALE_CLAIM_MS = 60000;
|
|
32
|
+
function withTimeout(promise, ms) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const timer = setTimeout(() => resolve(null), ms);
|
|
35
|
+
timer.unref();
|
|
36
|
+
promise.then((value) => {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
resolve(value);
|
|
39
|
+
}, () => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
resolve(null);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Short stable label for a session, used in spool file names (`.s<hash>-t<turn>.json`). */
|
|
46
|
+
function sessionTag(sessionId) {
|
|
47
|
+
return `s${createHash('sha1').update(sessionId).digest('hex').slice(0, 10)}`;
|
|
48
|
+
}
|
|
49
|
+
function parseTurnTag(file) {
|
|
50
|
+
const match = /\.(s[0-9a-f]{10})-t(\d+)\.json$/.exec(path.basename(file));
|
|
51
|
+
return match ? { session: match[1], turn: Number(match[2]) } : undefined;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Each turn carries the whole conversation so far and the server keeps only a session's newest turn,
|
|
55
|
+
* so a spooled turn is pointless once a newer turn of the same session has been DELIVERED: its text is
|
|
56
|
+
* already in that one, and replaying it would only burn upload time and attempts. (Only delivery
|
|
57
|
+
* counts: a newer turn that is merely spooled can still be refused and dropped, so its predecessor
|
|
58
|
+
* stays until it lands.) `floors` maps a session tag to
|
|
59
|
+
* the turn number below which its spooled turns can go (an absent session is left alone).
|
|
60
|
+
*/
|
|
61
|
+
async function discardSuperseded(floors) {
|
|
62
|
+
if (floors.size === 0) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
for (const file of await listTurnFiles()) {
|
|
66
|
+
const tag = parseTurnTag(file);
|
|
67
|
+
const floor = tag ? floors.get(tag.session) : undefined;
|
|
68
|
+
if (tag && floor !== undefined && tag.turn < floor) {
|
|
69
|
+
await removePendingRecord(file);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Bound the spool. The SDK reports every failure the same way (null), so a turn the server will
|
|
75
|
+
* never accept (revoked key, oversized body) looks just like an outage; without a cap such turns —
|
|
76
|
+
* each holding the key and the turn text — would pile up forever.
|
|
77
|
+
*/
|
|
78
|
+
async function pruneSpool() {
|
|
79
|
+
const files = await listTurnFiles();
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
const kept = [];
|
|
82
|
+
let expired = 0;
|
|
83
|
+
for (const file of files) {
|
|
84
|
+
try {
|
|
85
|
+
const info = await fs.stat(file);
|
|
86
|
+
if (now - info.mtimeMs > SPOOL_TTL_MS) {
|
|
87
|
+
await removePendingRecord(file);
|
|
88
|
+
expired += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
kept.push({ file, size: info.size });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Vanished underneath us.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Keep the newest records that fit both caps; the oldest go first.
|
|
98
|
+
let over = Math.max(0, kept.length - MAX_SPOOLED);
|
|
99
|
+
let bytes = kept.slice(over).reduce((n, k) => n + k.size, 0);
|
|
100
|
+
while (over < kept.length - 1 && bytes > MAX_SPOOL_BYTES) {
|
|
101
|
+
bytes -= kept[over].size;
|
|
102
|
+
over += 1;
|
|
103
|
+
}
|
|
104
|
+
for (const { file } of kept.slice(0, over)) {
|
|
105
|
+
await removePendingRecord(file);
|
|
106
|
+
}
|
|
107
|
+
// A dropped turn is data loss; hook.log is where people are told to look (counts only, no content).
|
|
108
|
+
if (expired > 0) {
|
|
109
|
+
await logHookEvent(`capture-turn: dropped ${expired} spooled turn(s) 7 days after their last attempt.`);
|
|
110
|
+
}
|
|
111
|
+
if (over > 0) {
|
|
112
|
+
await logHookEvent(`capture-turn: dropped ${over} oldest spooled turn(s) over the ${MAX_SPOOLED}-turn / ${MAX_SPOOL_BYTES >> 20} MB cap.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Take exclusive ownership of a spooled record by renaming it out of the `*.json` listing. */
|
|
116
|
+
async function claim(file) {
|
|
117
|
+
const claimed = `${file}${CLAIM_SUFFIX}`;
|
|
118
|
+
try {
|
|
119
|
+
// rename keeps the file's mtime, and a spooled record is usually older than STALE_CLAIM_MS. Touch it
|
|
120
|
+
// BEFORE the rename so the claim is born fresh: otherwise, for an instant, it would look abandoned
|
|
121
|
+
// and a concurrent run's reclaimStaleClaims could take it back. (A missing file means another run
|
|
122
|
+
// already claimed it.)
|
|
123
|
+
const now = new Date();
|
|
124
|
+
await fs.utimes(file, now, now);
|
|
125
|
+
await fs.rename(file, claimed);
|
|
126
|
+
return claimed;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null; // another hook run got there first
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Give back claims held by a process that died mid-replay, so those turns aren't stranded. */
|
|
133
|
+
async function reclaimStaleClaims() {
|
|
134
|
+
try {
|
|
135
|
+
const dir = pendingDir();
|
|
136
|
+
for (const name of await fs.readdir(dir)) {
|
|
137
|
+
const isClaim = name.startsWith(`${TURN_RECORD_COMMAND}-`) && name.endsWith(`.json${CLAIM_SUFFIX}`);
|
|
138
|
+
// atomicWriteFile's temp file, left behind if a process died mid-write of a claimed record.
|
|
139
|
+
const isTemp = name.startsWith(`.${TURN_RECORD_COMMAND}-`) && name.endsWith('.tmp');
|
|
140
|
+
if (!isClaim && !isTemp) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const file = path.join(dir, name);
|
|
144
|
+
try {
|
|
145
|
+
if (Date.now() - (await fs.stat(file)).mtimeMs > STALE_CLAIM_MS) {
|
|
146
|
+
await (isClaim ? fs.rename(file, file.slice(0, -CLAIM_SUFFIX.length)) : fs.rm(file, { force: true }));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Gone or being handled by someone else.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Housekeeping only.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Re-send turns spooled by earlier failures, NEWEST first: after a key rotation the old records carry
|
|
160
|
+
* a dead key and fail forever, and the turns that matter are the recent ones behind them. The server
|
|
161
|
+
* does not dedup, so a replay that lands twice is a duplicate log: each record is claimed atomically
|
|
162
|
+
* (overlapping hook runs can't both send it) and dropped after MAX_REPLAY_ATTEMPTS failures. One
|
|
163
|
+
* record the server keeps refusing doesn't block the rest; the run stops after several failures in a
|
|
164
|
+
* row or once its time budget is spent. The spool is pruned first so stale records age out.
|
|
165
|
+
*/
|
|
166
|
+
export async function replaySpooledTurns(timeoutMs = SEND_TIMEOUT_MS) {
|
|
167
|
+
await reclaimStaleClaims();
|
|
168
|
+
await pruneSpool();
|
|
169
|
+
const files = (await listTurnFiles()).reverse();
|
|
170
|
+
const deadline = Date.now() + REPLAY_BUDGET_MS;
|
|
171
|
+
let attempted = 0;
|
|
172
|
+
let failures = 0;
|
|
173
|
+
for (const file of files) {
|
|
174
|
+
// Never start an upload that could outlive the budget: a hook killed mid-upload leaves a claim
|
|
175
|
+
// and, if the server already took the turn, a duplicate on reclaim.
|
|
176
|
+
const remaining = deadline - Date.now();
|
|
177
|
+
if (attempted >= RETRY_BATCH || failures >= MAX_CONSECUTIVE_FAILURES || remaining < MIN_UPLOAD_MS) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const uploadMs = Math.min(timeoutMs, remaining);
|
|
181
|
+
const claimed = await claim(file);
|
|
182
|
+
if (!claimed) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
attempted += 1;
|
|
186
|
+
let record;
|
|
187
|
+
try {
|
|
188
|
+
record = await readPendingRecord(claimed);
|
|
189
|
+
if (!record || typeof record !== 'object') {
|
|
190
|
+
throw new Error('not a record'); // e.g. the JSON literal `null`
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
await removePendingRecord(claimed); // unreadable: it can never be sent
|
|
195
|
+
await logHookEvent(`capture-turn: discarded an unreadable spooled turn (${path.basename(file)}).`);
|
|
196
|
+
failures += 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// A hook killed mid-upload on the last allowed attempt leaves the cap reached but the record still
|
|
200
|
+
// here: give up now rather than make one more (possibly duplicating) attempt.
|
|
201
|
+
if ((record.attempts ?? 0) >= MAX_REPLAY_ATTEMPTS) {
|
|
202
|
+
await removePendingRecord(claimed);
|
|
203
|
+
await logHookEvent(`capture-turn: gave up on spooled turn ${path.basename(file)} after ${record.attempts} replay attempts.`);
|
|
204
|
+
failures += 1;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
// Count the attempt BEFORE the upload: a hook killed mid-upload leaves its claim to be reclaimed,
|
|
208
|
+
// and a death like that must still use up one of the record's attempts. (The server doesn't dedup,
|
|
209
|
+
// so every attempt that actually landed is another duplicate log.) `record` keeps the previous
|
|
210
|
+
// count, which is what the upload's replay_attempt marker is derived from.
|
|
211
|
+
const attempts = (record.attempts ?? 0) + 1;
|
|
212
|
+
try {
|
|
213
|
+
await atomicWriteFile(claimed, `${JSON.stringify({ ...record, attempts }, null, 2)}\n`, 0o600);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// Not persisted: fall through, the upload still runs.
|
|
217
|
+
}
|
|
218
|
+
// withTimeout turns a rejected upload into null, so this never throws.
|
|
219
|
+
const uploadStarted = Date.now();
|
|
220
|
+
const ok = record.kind === 'turn' && !!(await withTimeout(uploadRecord(record, { timeoutMs: uploadMs }), uploadMs));
|
|
221
|
+
if (ok || attempts >= MAX_REPLAY_ATTEMPTS) {
|
|
222
|
+
await removePendingRecord(claimed);
|
|
223
|
+
if (ok) {
|
|
224
|
+
// Delivered: older spooled turns of this session are now pointless (this one holds their text).
|
|
225
|
+
const tag = parseTurnTag(file);
|
|
226
|
+
if (tag) {
|
|
227
|
+
await discardSuperseded(new Map([[tag.session, tag.turn]]));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!ok) {
|
|
231
|
+
await logHookEvent(`capture-turn: gave up on spooled turn ${path.basename(file)} after ${attempts} failed replays.`);
|
|
232
|
+
}
|
|
233
|
+
failures = ok ? 0 : failures + 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
failures += 1;
|
|
237
|
+
// This replay ran out the clock, so the server may have stored it even if the original send was
|
|
238
|
+
// refused outright: from now on the record's replays are possible duplicates.
|
|
239
|
+
if (!record.ambiguous && Date.now() - uploadStarted >= uploadMs - 100) {
|
|
240
|
+
try {
|
|
241
|
+
await atomicWriteFile(claimed, `${JSON.stringify({ ...record, attempts, ambiguous: true }, null, 2)}\n`, 0o600);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// Keep the earlier copy.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
await fs.rename(claimed, file).catch(() => undefined); // on failure the next reclaim hands it back
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** This turn was DELIVERED: older spooled turns of its session no longer matter (it holds their text). */
|
|
251
|
+
async function discardSupersededBy(opts) {
|
|
252
|
+
if (opts.session && opts.turn) {
|
|
253
|
+
await discardSuperseded(new Map([[sessionTag(opts.session), opts.turn]]));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Send one turn envelope to Coolhand with the supplied public key. On any failure (network, timeout,
|
|
258
|
+
* server refusal) the turn is spooled to `~/.coolhand/pending/` carrying the key and base url, so it
|
|
259
|
+
* is replayed (see {@link replaySpooledTurns}, which the caller runs after a successful send), without a login.
|
|
260
|
+
* Returns `'sent'`, `'spooled'`, or `'failed'` when the send failed and the turn could not be spooled
|
|
261
|
+
* either (unwritable pending dir, disk full). Throws only for an unusable `baseUrl`.
|
|
262
|
+
*/
|
|
263
|
+
export async function sendTurn(envelope, opts) {
|
|
264
|
+
const baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL;
|
|
265
|
+
const timeoutMs = opts.timeoutMs ?? SEND_TIMEOUT_MS;
|
|
266
|
+
let coolhand;
|
|
267
|
+
try {
|
|
268
|
+
// The SDK validates baseUrl in its constructor (https required; http only for localhost).
|
|
269
|
+
coolhand = new Coolhand({ apiKey: opts.apiKey, baseUrl, silent: true, requestTimeoutMs: timeoutMs });
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
// This message goes to hook.log: never write credentials embedded in the url (https://user:pass@host).
|
|
273
|
+
const shown = baseUrl.replace(/\/\/[^/@]*@/, '//');
|
|
274
|
+
throw new CliError('INVALID_BASE_URL', `Invalid base url: ${shown} (${err.message})`);
|
|
275
|
+
}
|
|
276
|
+
const started = Date.now();
|
|
277
|
+
const result = await withTimeout(logTurn(coolhand, envelope, { collector: opts.collector, metadata: opts.metadata, declared: opts.declared }), timeoutMs);
|
|
278
|
+
if (result === null || result === undefined) {
|
|
279
|
+
// A quick failure most likely means the server did not take the turn (not a guarantee: the SDK
|
|
280
|
+
// reports every failure as null). Running out the clock means we can't know, and (with no
|
|
281
|
+
// server-side dedup yet) a replay of it may be a duplicate: record that.
|
|
282
|
+
const ambiguous = Date.now() - started >= timeoutMs - 100;
|
|
283
|
+
try {
|
|
284
|
+
await savePendingRecord({
|
|
285
|
+
command: TURN_RECORD_COMMAND,
|
|
286
|
+
kind: 'turn',
|
|
287
|
+
payload: { envelope, collector: opts.collector, metadata: opts.metadata, ...(opts.declared ? { declared: opts.declared } : {}) },
|
|
288
|
+
apiKey: opts.apiKey,
|
|
289
|
+
baseUrl,
|
|
290
|
+
...(ambiguous ? { ambiguous: true } : {}),
|
|
291
|
+
...(opts.session && opts.turn ? { fileTag: `${sessionTag(opts.session)}-t${opts.turn}` } : {}),
|
|
292
|
+
savedAt: new Date().toISOString(),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return 'failed'; // the caller still advances the session's state and records the loss
|
|
297
|
+
}
|
|
298
|
+
// Housekeeping must not change the outcome: the turn is already safely spooled.
|
|
299
|
+
await pruneSpool().catch(() => undefined);
|
|
300
|
+
return 'spooled';
|
|
301
|
+
}
|
|
302
|
+
await discardSupersededBy(opts).catch(() => undefined);
|
|
303
|
+
return 'sent';
|
|
304
|
+
}
|
|
305
|
+
//# sourceMappingURL=send-turn.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"send-turn.js","sourceRoot":"","sources":["../../src/hooks/send-turn.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run `fn` with every `console` method turned into a no-op, then restore them. coolhand-node
|
|
3
|
+
* ignores its `silent: true` option when a request fails (it calls `console.error` regardless), and
|
|
4
|
+
* a hook must put nothing on stdout or stderr — whatever it prints lands in the agent's hook channel.
|
|
5
|
+
*/
|
|
6
|
+
export declare function withSilencedConsole<T>(fn: () => Promise<T>): Promise<T>;
|
|
7
|
+
//# sourceMappingURL=silence.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"silence.d.ts","sourceRoot":"","sources":["../../src/hooks/silence.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,wBAAsB,mBAAmB,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAa7E"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const METHODS = ['log', 'info', 'warn', 'error', 'debug'];
|
|
2
|
+
/**
|
|
3
|
+
* Run `fn` with every `console` method turned into a no-op, then restore them. coolhand-node
|
|
4
|
+
* ignores its `silent: true` option when a request fails (it calls `console.error` regardless), and
|
|
5
|
+
* a hook must put nothing on stdout or stderr — whatever it prints lands in the agent's hook channel.
|
|
6
|
+
*/
|
|
7
|
+
export async function withSilencedConsole(fn) {
|
|
8
|
+
const target = console;
|
|
9
|
+
const saved = METHODS.map((method) => target[method]);
|
|
10
|
+
for (const method of METHODS) {
|
|
11
|
+
target[method] = () => undefined;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
return await fn();
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
METHODS.forEach((method, i) => {
|
|
18
|
+
target[method] = saved[i];
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=silence.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"silence.js","sourceRoot":"","sources":["../../src/hooks/silence.ts"],"names":[],"mappings":"AAAA,MAAM,OAAO,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAU,CAAC;AAEnE;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAI,EAAoB;IAC/D,MAAM,MAAM,GAAG,OAA6C,CAAC;IAC7D,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;IACtD,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,MAAM,CAAC,MAAM,CAAC,GAAG,GAAG,EAAE,CAAC,SAAS,CAAC;IACnC,CAAC;IACD,IAAI,CAAC;QACH,OAAO,MAAM,EAAE,EAAE,CAAC;IACpB,CAAC;YAAS,CAAC;QACT,OAAO,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE;YAC5B,MAAM,CAAC,MAAM,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QAC5B,CAAC,CAAC,CAAC;IACL,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { HookInput } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Read and parse the JSON object an agent writes to a hook's stdin. Returns `null` for a TTY (a
|
|
4
|
+
* human ran the command by hand), empty input, invalid JSON, or a non-object — a hook must never
|
|
5
|
+
* throw over a payload it doesn't understand. Gives up after `timeoutMs` so a hook whose agent
|
|
6
|
+
* never closes stdin can't hang the agent.
|
|
7
|
+
*/
|
|
8
|
+
export declare function readHookInput(stream?: NodeJS.ReadableStream & {
|
|
9
|
+
isTTY?: boolean;
|
|
10
|
+
}, timeoutMs?: number): Promise<HookInput | null>;
|
|
11
|
+
//# sourceMappingURL=stdin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stdin.d.ts","sourceRoot":"","sources":["../../src/hooks/stdin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAK5C;;;;;GAKG;AACH,wBAAsB,aAAa,CACjC,MAAM,GAAE,MAAM,CAAC,cAAc,GAAG;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAkB,EACnE,SAAS,SAAO,GACf,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CA0C3B"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** A hook payload is a few KB; anything bigger is not one. */
|
|
2
|
+
const MAX_STDIN_BYTES = 8 * 1024 * 1024;
|
|
3
|
+
/**
|
|
4
|
+
* Read and parse the JSON object an agent writes to a hook's stdin. Returns `null` for a TTY (a
|
|
5
|
+
* human ran the command by hand), empty input, invalid JSON, or a non-object — a hook must never
|
|
6
|
+
* throw over a payload it doesn't understand. Gives up after `timeoutMs` so a hook whose agent
|
|
7
|
+
* never closes stdin can't hang the agent.
|
|
8
|
+
*/
|
|
9
|
+
export async function readHookInput(stream = process.stdin, timeoutMs = 5000) {
|
|
10
|
+
if (stream.isTTY) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const raw = await new Promise((resolve) => {
|
|
14
|
+
const chunks = [];
|
|
15
|
+
let size = 0;
|
|
16
|
+
let settled = false;
|
|
17
|
+
const finish = () => {
|
|
18
|
+
if (settled) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
settled = true;
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
24
|
+
};
|
|
25
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
26
|
+
timer.unref();
|
|
27
|
+
stream.on('data', (chunk) => {
|
|
28
|
+
const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
29
|
+
size += buf.length;
|
|
30
|
+
if (size > MAX_STDIN_BYTES) {
|
|
31
|
+
chunks.length = 0;
|
|
32
|
+
finish();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
chunks.push(buf);
|
|
36
|
+
});
|
|
37
|
+
stream.on('end', finish);
|
|
38
|
+
stream.on('error', finish);
|
|
39
|
+
});
|
|
40
|
+
if (!raw.trim()) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=stdin.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stdin.js","sourceRoot":"","sources":["../../src/hooks/stdin.ts"],"names":[],"mappings":"AAEA,8DAA8D;AAC9D,MAAM,eAAe,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC;AAExC;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,SAAsD,OAAO,CAAC,KAAK,EACnE,SAAS,GAAG,IAAI;IAEhB,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QACjB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,GAAG,GAAG,MAAM,IAAI,OAAO,CAAS,CAAC,OAAO,EAAE,EAAE;QAChD,MAAM,MAAM,GAAa,EAAE,CAAC;QAC5B,IAAI,IAAI,GAAG,CAAC,CAAC;QACb,IAAI,OAAO,GAAG,KAAK,CAAC;QACpB,MAAM,MAAM,GAAG,GAAS,EAAE;YACxB,IAAI,OAAO,EAAE,CAAC;gBACZ,OAAO;YACT,CAAC;YACD,OAAO,GAAG,IAAI,CAAC;YACf,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;QAClD,CAAC,CAAC;QACF,MAAM,KAAK,GAAG,UAAU,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;QAC5C,KAAK,CAAC,KAAK,EAAE,CAAC;QACd,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAsB,EAAE,EAAE;YAC3C,MAAM,GAAG,GAAG,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;YACnE,IAAI,IAAI,GAAG,CAAC,MAAM,CAAC;YACnB,IAAI,IAAI,GAAG,eAAe,EAAE,CAAC;gBAC3B,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC;gBAClB,MAAM,EAAE,CAAC;gBACT,OAAO;YACT,CAAC;YACD,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACnB,CAAC,CAAC,CAAC;QACH,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;QACzB,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAC7B,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,CAAC;QAChB,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,CAAC;QACH,MAAM,MAAM,GAAY,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACxC,OAAO,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAE,MAAoB,CAAC,CAAC,CAAC,IAAI,CAAC;IACvG,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { TesterIdentity } from './identity.js';
|
|
2
|
+
/**
|
|
3
|
+
* Per-session progress for `capture-turn`. One small file per session — deliberately NOT part of
|
|
4
|
+
* the shared `capture-state.json`, which is read-modify-written without a lock and would race with
|
|
5
|
+
* a hook that fires after every turn.
|
|
6
|
+
*/
|
|
7
|
+
export interface TurnState {
|
|
8
|
+
/** Turns already sent for this session; the next turn gets this + 1. */
|
|
9
|
+
turnIndex: number;
|
|
10
|
+
/** Digest of the last turn sent, so a hook that fires twice for one turn sends it once. */
|
|
11
|
+
lastDigest?: string;
|
|
12
|
+
/** Who this session is attributed to, resolved on its first turn so git isn't consulted every turn. */
|
|
13
|
+
tester?: TesterIdentity;
|
|
14
|
+
/**
|
|
15
|
+
* When the session's first captured turn was written (ISO 8601). History sent with later turns
|
|
16
|
+
* starts here, so nothing said before the skill was invoked is uploaded.
|
|
17
|
+
*/
|
|
18
|
+
anchor?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Timestamps of the exchanges a hook actually captured, newest last (bounded). History sent with a
|
|
21
|
+
* later turn is limited to these: what the user typed while capture was off (a resumed session, the
|
|
22
|
+
* kill switch, no key, a killed hook) is never swept up into a later upload.
|
|
23
|
+
*/
|
|
24
|
+
captured?: string[];
|
|
25
|
+
/** The anchor was seeded after turn 1, so the start of the conversation was never sent (always flagged). */
|
|
26
|
+
anchorLate?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* The skill's text (the body Claude Code injected when it was invoked), kept once found so every
|
|
29
|
+
* later turn can send it without the invocation still being inside the transcript window.
|
|
30
|
+
*/
|
|
31
|
+
skillText?: string;
|
|
32
|
+
}
|
|
33
|
+
/** `captured` with one more exchange recorded (de-duplicated, bounded to the newest MAX_CAPTURED_STAMPS). */
|
|
34
|
+
export declare function withCaptured(captured: readonly string[] | undefined, stamp: string | undefined): string[];
|
|
35
|
+
export declare function turnsDir(): string;
|
|
36
|
+
export declare function loadTurnState(agent: string, sessionId: string): Promise<TurnState>;
|
|
37
|
+
export declare function saveTurnState(agent: string, sessionId: string, state: TurnState): Promise<void>;
|
|
38
|
+
/** Identity of a turn: its text plus, when known, the agent's own id for it. */
|
|
39
|
+
export declare function digestTurn(userText: string | undefined, assistantText: string, turnKey?: string): string;
|
|
40
|
+
//# sourceMappingURL=turn-state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"turn-state.d.ts","sourceRoot":"","sources":["../../src/hooks/turn-state.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAKpD;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,wEAAwE;IACxE,SAAS,EAAE,MAAM,CAAC;IAClB,2FAA2F;IAC3F,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uGAAuG;IACvG,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,4GAA4G;IAC5G,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAID,6GAA6G;AAC7G,wBAAgB,YAAY,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAMzG;AAID,wBAAgB,QAAQ,IAAI,MAAM,CAEjC;AASD,wBAAsB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAoBxF;AAED,wBAAsB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAOrG;AAED,gFAAgF;AAChF,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,aAAa,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAQxG"}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { configDir, atomicWriteFile } from '../config.js';
|
|
5
|
+
const DIR_MODE = 0o700;
|
|
6
|
+
const FILE_MODE = 0o600;
|
|
7
|
+
const MAX_CAPTURED_STAMPS = 1000;
|
|
8
|
+
/** `captured` with one more exchange recorded (de-duplicated, bounded to the newest MAX_CAPTURED_STAMPS). */
|
|
9
|
+
export function withCaptured(captured, stamp) {
|
|
10
|
+
const all = captured ?? [];
|
|
11
|
+
if (!stamp || all.includes(stamp)) {
|
|
12
|
+
return [...all];
|
|
13
|
+
}
|
|
14
|
+
return [...all, stamp].slice(-MAX_CAPTURED_STAMPS);
|
|
15
|
+
}
|
|
16
|
+
const TESTER_SOURCES = ['env', 'git', 'machine'];
|
|
17
|
+
export function turnsDir() {
|
|
18
|
+
return path.join(configDir(), 'capture-turns');
|
|
19
|
+
}
|
|
20
|
+
function statePath(agent, sessionId) {
|
|
21
|
+
const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 96);
|
|
22
|
+
// The hash keeps two ids that sanitise to the same string apart.
|
|
23
|
+
const hash = createHash('sha1').update(sessionId).digest('hex').slice(0, 8);
|
|
24
|
+
return path.join(turnsDir(), `${agent}-${safe}-${hash}.json`);
|
|
25
|
+
}
|
|
26
|
+
export async function loadTurnState(agent, sessionId) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(await fs.readFile(statePath(agent, sessionId), 'utf8'));
|
|
29
|
+
return {
|
|
30
|
+
turnIndex: typeof parsed.turnIndex === 'number' && parsed.turnIndex >= 0 ? Math.floor(parsed.turnIndex) : 0,
|
|
31
|
+
lastDigest: typeof parsed.lastDigest === 'string' ? parsed.lastDigest : undefined,
|
|
32
|
+
anchor: typeof parsed.anchor === 'string' && !Number.isNaN(Date.parse(parsed.anchor)) ? parsed.anchor : undefined,
|
|
33
|
+
captured: Array.isArray(parsed.captured) ? parsed.captured.filter((x) => typeof x === 'string').slice(-MAX_CAPTURED_STAMPS) : undefined,
|
|
34
|
+
anchorLate: parsed.anchorLate === true ? true : undefined,
|
|
35
|
+
skillText: typeof parsed.skillText === 'string' && parsed.skillText ? parsed.skillText : undefined,
|
|
36
|
+
tester: parsed.tester &&
|
|
37
|
+
typeof parsed.tester.id === 'string' &&
|
|
38
|
+
TESTER_SOURCES.includes(parsed.tester.source)
|
|
39
|
+
? parsed.tester
|
|
40
|
+
: undefined,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { turnIndex: 0 };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function saveTurnState(agent, sessionId, state) {
|
|
48
|
+
await fs.mkdir(turnsDir(), { recursive: true, mode: DIR_MODE });
|
|
49
|
+
await atomicWriteFile(statePath(agent, sessionId), `${JSON.stringify(state)}\n`, FILE_MODE);
|
|
50
|
+
if (state.turnIndex === 1) {
|
|
51
|
+
// A session's first turn is the cheap, once-per-session moment to clear out stale sessions.
|
|
52
|
+
await pruneOldState();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Identity of a turn: its text plus, when known, the agent's own id for it. */
|
|
56
|
+
export function digestTurn(userText, assistantText, turnKey) {
|
|
57
|
+
return createHash('sha256')
|
|
58
|
+
.update(userText ?? '')
|
|
59
|
+
.update('\0')
|
|
60
|
+
.update(assistantText)
|
|
61
|
+
.update('\0')
|
|
62
|
+
.update(turnKey ?? '')
|
|
63
|
+
.digest('hex');
|
|
64
|
+
}
|
|
65
|
+
const STATE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
66
|
+
const TEMP_TTL_MS = 60 * 60 * 1000;
|
|
67
|
+
/** Delete per-session state files untouched for 30 days. Best-effort; never throws. */
|
|
68
|
+
async function pruneOldState() {
|
|
69
|
+
let names;
|
|
70
|
+
try {
|
|
71
|
+
names = await fs.readdir(turnsDir());
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return; // Housekeeping only.
|
|
75
|
+
}
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
for (const name of names) {
|
|
78
|
+
// State files age out after 30 days; an `atomicWriteFile` temp file (left by a hook killed
|
|
79
|
+
// mid-write) is stale after an hour.
|
|
80
|
+
const isState = name.endsWith('.json');
|
|
81
|
+
const isTemp = name.startsWith('.') && name.endsWith('.tmp');
|
|
82
|
+
if (!isState && !isTemp) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const file = path.join(turnsDir(), name);
|
|
86
|
+
try {
|
|
87
|
+
// Per file: another session's hook may remove one between the readdir and here.
|
|
88
|
+
if (now - (await fs.stat(file)).mtimeMs > (isState ? STATE_TTL_MS : TEMP_TTL_MS)) {
|
|
89
|
+
await fs.rm(file, { force: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Gone already.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=turn-state.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"turn-state.js","sourceRoot":"","sources":["../../src/hooks/turn-state.ts"],"names":[],"mappings":"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"}
|