@kelceyp/caw-server 1.0.290 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kelceyp/caw-server",
3
- "version": "1.0.290",
3
+ "version": "1.1.0",
4
4
  "description": "CAW server - REST API, MCP server and core features",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -19,13 +19,12 @@
19
19
  "build": "bash scripts/bun-build.sh",
20
20
  "build:client": "cd ../client && bun run build",
21
21
  "build:cli": "cd ../cli && bun run build && cp dist/cli.js ../server/dist/",
22
- "build:all": "bun run clean && bun run generate:applyPojo && bun run build:client && bun run build && cp src/core/services/embedding-worker.js dist/embedding-worker.js && cp src/core/agents/pty-wrapper.mjs dist/pty-wrapper.mjs && cp src/core/agents/pty-deferred-teardown.mjs dist/pty-deferred-teardown.mjs && cp src/core/agents/pty-emit-result.mjs dist/pty-emit-result.mjs && cp src/core/agents/teardownReasons.mjs dist/teardownReasons.mjs && cp src/core/agents/pty-non-interactive-setup.mjs dist/pty-non-interactive-setup.mjs && cp src/core/agents/transcript-tailer.mjs dist/transcript-tailer.mjs && cp src/core/agents/prompt-rejection-detector.mjs dist/prompt-rejection-detector.mjs && cp src/core/agents/claude-config-dir-guard.mjs dist/claude-config-dir-guard.mjs && bun run build:cli",
22
+ "build:all": "bun run clean && bun run generate:applyPojo && bun run build:client && bun run build && cp src/core/services/embedding-worker.js dist/embedding-worker.js && cp src/core/agents/claude-config-dir-guard.mjs dist/claude-config-dir-guard.mjs && bun run build:cli",
23
23
  "dev": "STATIC_PATH=../client/src bun --define __BUILTIN_POSTHOG_KEY__=null run src/main.js",
24
24
  "start": "node dist/main.js",
25
25
  "test": "bun test --define __BUILTIN_POSTHOG_KEY__=null test/unit test/integration test/component test/cli test/investigation && (cd ../client && bun test)",
26
26
  "test:e2e": "bun test --define __BUILTIN_POSTHOG_KEY__=null test/e2e",
27
- "perf:updateStoryState": "bun run test/perf/updateStoryState.perf.js",
28
- "postinstall": "find node_modules/node-pty -name spawn-helper -exec chmod +x {} + 2>/dev/null || true"
27
+ "perf:updateStoryState": "bun run test/perf/updateStoryState.perf.js"
29
28
  },
30
29
  "keywords": [
31
30
  "caw",
@@ -52,7 +51,6 @@
52
51
  "is_js": "^0.9.0",
53
52
  "kysely": "^0.29.0",
54
53
  "multer": "^2.1.1",
55
- "node-pty": "^1.1.0",
56
54
  "posthog-node": "^5.48.1",
57
55
  "safe-regex": "^2.1.1",
58
56
  "ws": "^8.21.0",
@@ -1,158 +0,0 @@
1
- // prompt-rejection-detector.mjs — PromptRejectionDetector module.
2
- //
3
- // Evaluates JSONL transcript entries handed to it by the wrapper (Task 6
4
- // wiring) and, on a first conjunctive-predicate match, emits the failure line
5
- // on stdout exactly once. Unimported sibling of `pty-wrapper.mjs` at this
6
- // task's end-of-task; wired in at Task 6.
7
- //
8
- // Instance-owned mutable state — no module-level mutables. Two instances
9
- // constructed in the same test file do not share state. The `getEmissionState`
10
- // / `setEmissionState` callbacks are stored **by reference** so the coupling
11
- // with the wrapper's `resultEmitted`/`failureEmitted` variables stays live at
12
- // Task 6 — the detector never snapshots them at construction.
13
-
14
- // Anchored, case-sensitive, whitespace-trim-only. Any drift here breaks
15
- // downstream Task 7 drift-guard lockstep.
16
- const NORMALISED_REJECTION_TEXT = /^Prompt is too long$/;
17
-
18
- export function createPromptRejectionDetector({ log, getEmissionState, setEmissionState, tailer } = {}) {
19
- const emit = typeof log === 'function'
20
- ? log
21
- : (message) => { process.stderr.write(message + '\n'); };
22
-
23
- const state = {
24
- oneShotFired: false,
25
- transcriptUnresolvedFired: false,
26
- boundaryUnusableFired: false,
27
- boundaryUnusableCause: null,
28
- };
29
-
30
- function noteTranscriptUnresolved() {
31
- if (state.transcriptUnresolvedFired) return;
32
- state.transcriptUnresolvedFired = true;
33
- emit('prompt-rejection: refused, reason=transcript-unresolved');
34
- }
35
-
36
- // First-writer-wins on cause: second and later note calls are no-ops (item
37
- // 13-style idempotency without a separate flag — the state tuple stays
38
- // {oneShotFired, transcriptUnresolvedFired, boundaryUnusableFired,
39
- // boundaryUnusableCause}). The distinct `boundaryUnusableFired` flag
40
- // guards the leg's own once-per-turn emit so a subsequent noteBoundaryUnusable
41
- // call cannot squelch the leg (test (h)).
42
- function noteBoundaryUnusable(cause) {
43
- if (state.boundaryUnusableCause !== null) return;
44
- state.boundaryUnusableCause = cause;
45
- }
46
-
47
- function textBlockMatches(block) {
48
- if (!block || block.type !== 'text') return false;
49
- if (typeof block.text !== 'string') return false;
50
- return NORMALISED_REJECTION_TEXT.test(block.text.trim());
51
- }
52
-
53
- function findMatchingText(content) {
54
- for (const block of content) {
55
- if (textBlockMatches(block)) return block.text;
56
- }
57
- return null;
58
- }
59
-
60
- function consider(entry) {
61
- const uuid = entry && typeof entry === 'object' ? entry.uuid : undefined;
62
- const uuidField = uuid !== undefined ? `uuid=${uuid}` : 'uuid=undefined';
63
-
64
- // Leg 1: missing-flag
65
- if (!entry || entry.isApiErrorMessage !== true) {
66
- const code = entry && entry.error !== undefined ? entry.error : 'null';
67
- emit(`prompt-rejection: refused, reason=missing-flag ${uuidField} error=${code}`);
68
- return;
69
- }
70
-
71
- // Leg 2: wrong-typed-code (top-level entry.error, NOT entry.message.error)
72
- if (entry.error !== 'invalid_request') {
73
- const code = entry.error !== undefined ? entry.error : 'null';
74
- emit(`prompt-rejection: refused, reason=wrong-typed-code ${uuidField} error=${code}`);
75
- return;
76
- }
77
-
78
- // Leg 3: content-shape
79
- const content = entry.message && entry.message.content;
80
- if (!Array.isArray(content)) {
81
- emit(`prompt-rejection: refused, reason=content-shape ${uuidField} contentType=${typeof content}`);
82
- return;
83
- }
84
-
85
- // Leg 4: text-mismatch
86
- const matchedText = findMatchingText(content);
87
- if (matchedText === null) {
88
- emit(`prompt-rejection: refused, reason=text-mismatch ${uuidField} error=invalid_request`);
89
- return;
90
- }
91
-
92
- // Leg 5: boundary-unusable (at most once per turn, mirrors the tailer's
93
- // own one-shot boundary-unusable line)
94
- if (tailer && tailer.unusable === true) {
95
- if (!state.boundaryUnusableFired) {
96
- state.boundaryUnusableFired = true;
97
- const cause = state.boundaryUnusableCause !== null
98
- ? state.boundaryUnusableCause
99
- : 'unknown';
100
- emit(`prompt-rejection: refused, reason=boundary-unusable path=${tailer.path} cause=${cause}`);
101
- }
102
- return;
103
- }
104
-
105
- // Leg 6: no-uuid
106
- if (!uuid) {
107
- emit(`prompt-rejection: refused, reason=no-uuid error=invalid_request`);
108
- return;
109
- }
110
-
111
- // Leg 7: pre-baseline (defensive — the tailer already suppresses these)
112
- if (tailer && tailer.preBaselineUUIDs && tailer.preBaselineUUIDs.has(uuid)) {
113
- emit(`prompt-rejection: refused, reason=pre-baseline uuid=${uuid}`);
114
- return;
115
- }
116
-
117
- // Match survived every predicate leg. Apply the one-shot emission guard
118
- // immediately before writing to stdout so a concurrent Stop-hook
119
- // emitResult() cannot squeeze in between the check and the write.
120
- if (state.oneShotFired) {
121
- emit(`prompt-rejection: refused, reason=already-emitted uuid=${uuid}`);
122
- return;
123
- }
124
- const emissionState = typeof getEmissionState === 'function' ? getEmissionState() : null;
125
- if (emissionState === 'result' || emissionState === 'failure') {
126
- emit(`prompt-rejection: refused, reason=already-emitted uuid=${uuid}`);
127
- return;
128
- }
129
-
130
- // Detection log line — inside the guard, between the check and the
131
- // stdout write, so the log line matches the emission 1:1.
132
- emit(
133
- `prompt-rejection: matched entry uuid=${uuid} error=invalid_request `
134
- + `boundary=post-baseline — emitting failure and killing pty`
135
- );
136
-
137
- // stdout write MUST happen before setEmissionState('failure') returns
138
- // (load-bearing ordering invariant, item 10). A racing observer that
139
- // reads state after seeing the stdout line always finds `failure`.
140
- const failureLine = JSON.stringify({
141
- type: 'error',
142
- error: 'prompt_too_long',
143
- message: matchedText,
144
- }) + '\n';
145
- process.stdout.write(failureLine);
146
-
147
- if (typeof setEmissionState === 'function') {
148
- setEmissionState('failure');
149
- }
150
- state.oneShotFired = true;
151
- }
152
-
153
- return {
154
- consider,
155
- noteTranscriptUnresolved,
156
- noteBoundaryUnusable,
157
- };
158
- }
@@ -1,17 +0,0 @@
1
- import { readFileSync } from 'fs';
2
-
3
- export const readSentinelPayload = (sentinelPath) => {
4
- try {
5
- return JSON.parse(readFileSync(sentinelPath, 'utf-8'));
6
- } catch {
7
- return null;
8
- }
9
- };
10
-
11
- export const hasPendingWork = (payload) => {
12
- const tasks = payload?.background_tasks;
13
- if (Array.isArray(tasks) && tasks.length > 0) return true;
14
- const crons = payload?.session_crons;
15
- if (Array.isArray(crons) && crons.length > 0) return true;
16
- return false;
17
- };
@@ -1,204 +0,0 @@
1
- /**
2
- * Side-effect-free extraction of `emitResult()` from `pty-wrapper.mjs`.
3
- *
4
- * The wrapper's `onExit` handler reads the Stop-hook sentinel file, then hands
5
- * the parsed payload to this function to build either the `type:"result"` or
6
- * `type:"error"` stdout line. Splitting the branch construction out of the
7
- * wrapper mirrors `pty-deferred-teardown.mjs` (already extracts
8
- * `readSentinelPayload` and `hasPendingWork`) so the sentinel-success and
9
- * error branches can be unit-tested without spawning a real PTY.
10
- *
11
- * Inputs are function arguments only — no module-level state, no top-level
12
- * side effects, no file IO. The caller (the wrapper's delegator) reads the
13
- * sentinel via `readSentinelPayload()` and passes the parsed payload in as
14
- * `hookData`; a null / falsy `hookData` is a no-op, mirroring the wrapper's
15
- * pre-extraction `if (!existsSync(sentinelPath)) return;` guard one level up.
16
- *
17
- * Wire. This function writes at most one line, of one of two shapes:
18
- * - success line (Stop):
19
- * `{ type: 'result', result, metadata, stopFallback }`.
20
- * - error line (StopFailure, `hookData.error` truthy):
21
- * `{ type: 'error', error, message[, error_details], metadata, stopFallback }`
22
- * (As1071 design S4). `error_details` is the StopFailure payload's own
23
- * `error_details`, copied verbatim (any JSON type, `null` included) and
24
- * only when the payload has the key; it is diagnostic evidence, never a
25
- * reset-time source. The success line never carries it.
26
- * Both lines get `metadata` from the one builder, `buildMetadata()`, so the two
27
- * cannot drift: `{ sessionId, transcriptPath, permissionMode[, durationMs][, teardownReason] }`
28
- * (`durationMs` when both timestamps are given, `teardownReason` when the
29
- * wrapper passes one, omitted rather than `null` otherwise; fallback chains
30
- * `hookData.session_id || effectiveSessionId`,
31
- * `hookData.transcript_path || transcriptPath || null`,
32
- * `hookData.permission_mode || null`). On a real StopFailure turn the wrapper
33
- * passes `teardownReason: 'stop-failure'` and both timestamps, so the error
34
- * line meets `teardownReasons.mjs` contract point 2 too.
35
- * `stopFallback` (As1072) is appended as the LAST top-level key of both lines
36
- * and is not a `metadata` key. The success line is the pre-As1072 line with
37
- * that one key added before its closing brace. The no-op paths
38
- * (`alreadyEmitted`, a falsy `hookData`, and the `catch` below) write no line
39
- * at all.
40
- *
41
- * The double-emit guard (previously `if (resultEmitted || failureEmitted) return;`
42
- * at `pty-wrapper.mjs:401`) is preserved via the `alreadyEmitted` input: the
43
- * caller passes the current flag state, and the function no-ops when true.
44
- * The return value `{ emitted: 'result' | 'error' | null }` lets the caller
45
- * translate a successful call back into its module-level `resultEmitted` /
46
- * `failureEmitted` flags. This is not the wrapper's only writer of
47
- * `{ type: 'error', ... }` lines: As1080's `prompt-rejection-detector.mjs`
48
- * writes its own `prompt_too_long` line, which does not pass through here. A
49
- * vendor StopFailure reaches this function only when the Stop-hook sentinel
50
- * carries a structured `error` field.
51
- */
52
-
53
- // --- stopFallback vocabulary (As1072) ---
54
- //
55
- // What happened to the Stop-hook `last_assistant_message` fallback on a turn.
56
- // The wrapper writes the value on its own terminal line, so the component that
57
- // holds `hookData` is the one that decides it:
58
- // - `present` / `empty` / `absent` / `invalid` — written by `emitResult()` on
59
- // both lines, from `deriveStopFallback()`;
60
- // - `suppressed` — written by `emitResult()` on the success line only, when
61
- // the CAW_PTY_SUPPRESS_STOP_FALLBACK test override is set;
62
- // - `not-emitted` — NEVER written by the wrapper. No terminal line exists to
63
- // carry it, so only `claude-session.js` derives it, from the absence of a
64
- // `result` / `error` line.
65
- // `unknown` is deliberately in NEITHER export below. It is the record layer's
66
- // sentinel (`sentinels.js`) with one writer, `JobService.updateJobMetadata`,
67
- // which uses it when no value reached the record. Adding it here would create
68
- // a second writer.
69
- // The vocabulary lives in this module, the only wrapper-side writer, so the
70
- // wrapper's import closure (and with it `build:all` and `Dockerfile.session`)
71
- // does not grow.
72
-
73
- export const STOP_FALLBACK = Object.freeze({
74
- PRESENT: 'present',
75
- EMPTY: 'empty',
76
- ABSENT: 'absent',
77
- INVALID: 'invalid',
78
- SUPPRESSED: 'suppressed',
79
- NOT_EMITTED: 'not-emitted', // derived by claude-session.js; never written by the wrapper
80
- });
81
-
82
- // Exactly the values a wrapper line may carry; claude-session.js accepts only these.
83
- // A frozen array, not a Set: Object.freeze does not stop Set.prototype.add/delete.
84
- export const STOP_FALLBACK_WIRE_VALUES = Object.freeze([
85
- STOP_FALLBACK.PRESENT,
86
- STOP_FALLBACK.EMPTY,
87
- STOP_FALLBACK.ABSENT,
88
- STOP_FALLBACK.INVALID,
89
- STOP_FALLBACK.SUPPRESSED,
90
- ]);
91
-
92
- export const isStopFallbackWireValue = (value) =>
93
- typeof value === 'string' && STOP_FALLBACK_WIRE_VALUES.includes(value);
94
-
95
- /**
96
- * Classify the Stop / StopFailure payload's `last_assistant_message` by own-key
97
- * presence, not truthiness. First match wins:
98
- * 1. `suppressFallback` set → `suppressed`
99
- * 2. not an own key, or own with value `undefined` → `absent`
100
- * 3. not a string (`null` included) → `invalid`
101
- * 4. `''` → `empty`
102
- * 5. any other string (whitespace-only included) → `present`
103
- *
104
- * An own `undefined` is `absent` (As1072 KD3): `hookData` is parsed JSON, which
105
- * cannot carry one, and the vendor serialises an `undefined` value as a missing
106
- * key. Total: an omitted options argument means no override; a `hookData` that
107
- * cannot hold the own key (`undefined`, `null`, a primitive, an array) is
108
- * `absent`; it never throws and only returns wire values. `emitResult()` passes
109
- * `suppressFallback` on the success branch only.
110
- */
111
- export const deriveStopFallback = (hookData, options) => {
112
- if (options?.suppressFallback) return STOP_FALLBACK.SUPPRESSED;
113
- if (hookData === null || hookData === undefined) return STOP_FALLBACK.ABSENT;
114
- if (!Object.hasOwn(hookData, 'last_assistant_message')) return STOP_FALLBACK.ABSENT;
115
- const value = hookData.last_assistant_message;
116
- if (value === undefined) return STOP_FALLBACK.ABSENT;
117
- if (typeof value !== 'string') return STOP_FALLBACK.INVALID;
118
- if (value === '') return STOP_FALLBACK.EMPTY;
119
- return STOP_FALLBACK.PRESENT;
120
- };
121
-
122
- const defaultWrite = (line) => process.stdout.write(line);
123
- const defaultLog = () => {};
124
-
125
- // The one `metadata` builder both lines use (As1071 S4). Field order is part
126
- // of the success line's wire, which must stay byte-identical.
127
- const buildMetadata = ({ hookData, transcriptPath, effectiveSessionId, startedAt, endedAt, teardownReason }) => {
128
- const metadata = {
129
- sessionId: hookData.session_id || effectiveSessionId,
130
- transcriptPath: hookData.transcript_path || transcriptPath || null,
131
- permissionMode: hookData.permission_mode || null
132
- };
133
- if (startedAt !== null && endedAt !== null) {
134
- metadata.durationMs = endedAt - startedAt;
135
- }
136
- // As1033 Task 6: append teardownReason ('stop' | 'watchdog' |
137
- // 'stop-failure' | ...) so the downstream stream handler and the
138
- // JobRunner classifier gate can distinguish a clean Stop finish from a
139
- // watchdog force-kill. Omitted, never `null`, when the wrapper passes
140
- // none (`teardownReasons.mjs` contract point 3).
141
- if (teardownReason !== null) {
142
- metadata.teardownReason = teardownReason;
143
- }
144
- return metadata;
145
- };
146
-
147
- export function emitResult({
148
- hookData,
149
- transcriptPath = null,
150
- effectiveSessionId = null,
151
- startedAt = null,
152
- endedAt = null,
153
- teardownReason = null,
154
- alreadyEmitted = false,
155
- suppressFallback = false,
156
- write = defaultWrite,
157
- log = defaultLog
158
- } = {}) {
159
- if (alreadyEmitted) return { emitted: null };
160
- if (!hookData) return { emitted: null };
161
-
162
- try {
163
- const metadataInputs = { hookData, transcriptPath, effectiveSessionId, startedAt, endedAt, teardownReason };
164
-
165
- if (hookData.error) {
166
- const line = {
167
- type: 'error',
168
- error: hookData.error,
169
- message: hookData.last_assistant_message || ''
170
- };
171
- // Present-only and verbatim (As1071 S4): own-key presence, not
172
- // truthiness, so `null`, `false`, `0` and `''` are copied as-is.
173
- if (Object.hasOwn(hookData, 'error_details')) {
174
- line.error_details = hookData.error_details;
175
- }
176
- line.metadata = buildMetadata(metadataInputs);
177
- // No override on this branch (KD7): the line records the payload's
178
- // real state, never `suppressed`.
179
- line.stopFallback = deriveStopFallback(hookData);
180
- write(JSON.stringify(line) + '\n');
181
- return { emitted: 'error' };
182
- }
183
-
184
- const metadata = buildMetadata(metadataInputs);
185
- // D11 test-only override: when the wrapper's CAW_PTY_SUPPRESS_STOP_FALLBACK
186
- // gate is set, blank the `result` field on the success branch only. The
187
- // line still declares itself (As1072): `stopFallback: 'suppressed'`,
188
- // whatever the payload held, so the record can tell the override from a
189
- // real payload. `result` is still `''`, so the As1005 D11 transcript-tail
190
- // + override combination still reaches the loud empty-turn failure
191
- // downstream. `metadata` is untouched.
192
- const result = suppressFallback ? '' : (hookData.last_assistant_message || '');
193
- write(JSON.stringify({
194
- type: 'result',
195
- result,
196
- metadata,
197
- stopFallback: deriveStopFallback(hookData, { suppressFallback })
198
- }) + '\n');
199
- return { emitted: 'result' };
200
- } catch (err) {
201
- log(`Error reading sentinel: ${err.message}`);
202
- return { emitted: null };
203
- }
204
- }