open-claude-p 1.1.1 → 1.1.3

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/CHANGELOG.md CHANGED
@@ -7,6 +7,105 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.1.3] — 2026-05-19
11
+
12
+ Fix the `/compact` 24-hour hang and tighten the slash-command path so
13
+ skill invocations are not collateral damage. Driven by a captured
14
+ session (`4af68584-…`) where a manually-triggered `/compact` ran for
15
+ 57 s of compaction activity, returned to the input box with no
16
+ assistant turn, and left `runOneShot` waiting for a sentinel that — by
17
+ the upstream's design — could never arrive.
18
+
19
+ ### Fixed
20
+
21
+ - **`/compact` and other local-builtin slash commands no longer block
22
+ for `maxResponseMs`.** Claude TUI splits `/`-prefixed prompts into
23
+ two classes: *local builtins* (`/compact`, `/clear`, `/help`,
24
+ `/exit`, `/quit`, `/login`, `/logout`, `/cost`, `/status`, `/model`,
25
+ `/permissions`, `/config`) which run a local handler and never open
26
+ an `⏺` region, and *LLM-bearing slash invocations* (skills like
27
+ `/init`, `/review`, `/security-review`, plus every user-installed
28
+ skill) which DO go through the model. The driver previously
29
+ appended the OCP_END marker instruction to both and required
30
+ `hadAssistantText` before the completion detector's idle fallback
31
+ could fire — fine for the LLM-bearing class, fatal for builtins,
32
+ which would wait for an assistant region that never opened until
33
+ the 24 h hard timeout.
34
+
35
+ Now `runOneShot` matches the prompt against a narrow whitelist of
36
+ local builtins. On a match it (a) skips the OCP_END instruction
37
+ append (the TUI's command parser drops it as junk args anyway, and
38
+ appending it can pollute free-form-arg commands like `/bug`) and
39
+ (b) sets the detector's new `allowIdleWithoutResponse` flag so the
40
+ pre-sentinel idle path is reachable without a prior region entry.
41
+ Skills and unknown `/<name>` prompts are NOT in the whitelist and
42
+ keep the existing instruction + strict-idle-gate behaviour, so
43
+ their LLM responses still complete cleanly via the sentinel.
44
+
45
+ ### Added
46
+
47
+ - **`CompletionDetector.allowIdleWithoutResponse` (default false).**
48
+ When true, `_onTick`'s pre-sentinel idle fallback fires after
49
+ `preIdleMs` of silence even without a prior `assistant-region-
50
+ entered`. This is the policy switch the driver uses for local
51
+ builtins; library callers that drive prompts known to produce no
52
+ assistant turn can opt in directly.
53
+
54
+ ### Tests
55
+
56
+ - **End-to-end integration coverage for the slash-command path.**
57
+ `test/driver-slash-command.test.js` spawns the real driver against
58
+ `test/fixtures/fake-claude-tui.mjs` — a minimal node-pty fixture
59
+ that reproduces the captured `/compact` shape (spinner activity,
60
+ `Compacted` stdout, chevron return, no region, no sentinel) — and
61
+ asserts: `/compact` completes via `reason='idle'` in well under
62
+ `maxResponseMs`; a plain prompt still completes via `sentinel` with
63
+ no degraded-capture notice prefix; `/init` (a skill, not a builtin)
64
+ also completes via `sentinel`, proving the whitelist isn't too
65
+ greedy. Three new unit tests in `test/detector.test.js` cover the
66
+ bare `allowIdleWithoutResponse` flag.
67
+
68
+ ---
69
+
70
+ ## [1.1.2] — 2026-05-19
71
+
72
+ Recovery-path fixes for the sentinel-missing case. When the end-of-reply
73
+ marker fails to land — usually because claude omitted it or the PTY
74
+ frames were truncated — the driver's text extraction was returning
75
+ either an empty string or, worse, the entire re-rendered transcript of
76
+ a `--resume` session. Two changes correct that.
77
+
78
+ ### Fixed
79
+
80
+ - **`extractAssistantText` no longer slices from the FIRST `⏺`** when
81
+ the sentinel is missing. On a `--resume` run the buffer carries old
82
+ history regions before the current response; anchoring on the first
83
+ `⏺` returned the oldest history turn plus everything after it,
84
+ silently leaking prior turns into `result.text`. The fallback now
85
+ uses `lastIndexOf('⏺')` so the slice always covers the current
86
+ turn's response. Fresh (non-resumed) sessions are unaffected because
87
+ the buffer only has one region marker, so first == last.
88
+
89
+ ### Added
90
+
91
+ - **Degraded-capture notice prefix.** When `completionReason` is
92
+ `'idle'` (sentinel never seen, idle-fallback completed) or
93
+ `'jsonl-recovered'` (a stall was rescued by reading the upstream
94
+ JSONL session log), `result.text` is now prefixed with:
95
+
96
+ ```
97
+ [ocp] Streaming capture not detected — showing last result from {source}.
98
+ ```
99
+
100
+ where `{source}` is either `local session log` (JSONL had the
101
+ response) or `terminal buffer (best effort)` (JSONL was unavailable
102
+ and we fell back to the PTY slice above). Callers and chat UIs can
103
+ now distinguish a normal capture from a post-hoc recovery without
104
+ inspecting `completionReason`. The default success path
105
+ (`reason='sentinel'`) is unchanged — no prefix is added.
106
+
107
+ ---
108
+
10
109
  ## [1.1.1] — 2026-05-19
11
110
 
12
111
  Post-1.1.0 fixes driven by Cosmica integration testing. Several of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-claude-p",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "A PTY-backed compatibility shim for `claude -p` (Claude Code headless/print mode). Drives the interactive `claude` CLI via node-pty and exposes the same option surface and stream-json contract. Ships as both a library and an `ocp` CLI binary.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -37,6 +37,20 @@ export class CompletionDetector {
37
37
  * turn; when the (N+1)th turn
38
38
  * opens, the request is aborted
39
39
  * with reason 'max-turns'.
40
+ * @param {boolean} [opts.allowIdleWithoutResponse=false]
41
+ * Permit `_onTick`'s idle fallback
42
+ * even when no assistant region has
43
+ * opened. Set this for prompts that
44
+ * legitimately produce no assistant
45
+ * turn — slash commands (`/compact`,
46
+ * `/clear`, `/help`, …) whose
47
+ * upstream handler runs a local
48
+ * operation and returns directly to
49
+ * the input box. Without this flag
50
+ * the request would block until
51
+ * `maxResponseMs` (24 h default)
52
+ * because `hadAssistantText` stays
53
+ * false for the entire turn.
40
54
  */
41
55
  constructor({
42
56
  nonce,
@@ -44,6 +58,7 @@ export class CompletionDetector {
44
58
  preIdleMs = 8000,
45
59
  maxResponseMs = 60000,
46
60
  maxTurns,
61
+ allowIdleWithoutResponse = false,
47
62
  } = {}) {
48
63
  if (!nonce) throw new Error('CompletionDetector: nonce is required');
49
64
  this.nonce = nonce;
@@ -51,6 +66,7 @@ export class CompletionDetector {
51
66
  this.preIdleMs = preIdleMs;
52
67
  this.maxResponseMs = maxResponseMs;
53
68
  this.maxTurns = Number.isFinite(maxTurns) && maxTurns >= 0 ? maxTurns : null;
69
+ this.allowIdleWithoutResponse = !!allowIdleWithoutResponse;
54
70
 
55
71
  this.startTime = Date.now();
56
72
  this.regionEntered = false;
@@ -142,7 +158,10 @@ export class CompletionDetector {
142
158
  // Pre-sentinel fallback: only valid once we've seen any assistant
143
159
  // signal, with the longer `preIdleMs` threshold so that brief render
144
160
  // pauses (common during `--resume`) do not trip premature completion.
145
- if (this.hadAssistantText) {
161
+ // `allowIdleWithoutResponse` relaxes the gate for callers that know
162
+ // the turn legitimately produces no assistant region (e.g. slash
163
+ // commands like `/compact`).
164
+ if (this.hadAssistantText || this.allowIdleWithoutResponse) {
146
165
  if (idleFor >= this.preIdleMs) {
147
166
  this._complete('idle', false);
148
167
  }
package/src/index.js CHANGED
@@ -273,6 +273,39 @@ class Driver {
273
273
 
274
274
  const spawnArgs = buildSpawnArgs(req);
275
275
 
276
+ // Local-builtin slash command detection. Claude TUI has two distinct
277
+ // `/`-prefixed prompt classes:
278
+ //
279
+ // - **Local builtins** (this list) — handled entirely by the TUI's
280
+ // command dispatcher. No LLM turn runs, no `⏺` region opens, no
281
+ // sentinel can ever arrive. `/compact` is the motivating case:
282
+ // 57 s of compaction activity, then chevron returns silently,
283
+ // then ocp would block until `maxResponseMs` (24 h default)
284
+ // waiting for a response that by design never comes.
285
+ //
286
+ // - **LLM-bearing slash invocations** — skills (`/init`, `/review`,
287
+ // `/security-review`, custom user skills like `/ui-ux-pro-max`)
288
+ // and any unknown `/<name>` we don't recognise. These DO run
289
+ // the LLM, DO open `⏺`, and emit the sentinel like a normal
290
+ // prompt. They MUST keep the OCP_END instruction and the
291
+ // strict idle gate — relaxing either would drop the marker
292
+ // from a real LLM turn and trigger a false-positive idle
293
+ // completion (returning a half-streamed response prefixed
294
+ // with the 1.1.2 "Streaming capture not detected" notice).
295
+ //
296
+ // The list is intentionally narrow — only claude TUI commands
297
+ // confirmed to bypass the LLM. Anything not in the set (including
298
+ // every user-installed skill) goes through the standard path.
299
+ const LOCAL_BUILTIN_SLASH_COMMANDS = new Set([
300
+ 'compact', 'clear', 'help', 'exit', 'quit',
301
+ 'login', 'logout', 'cost', 'status',
302
+ 'model', 'permissions', 'config',
303
+ ]);
304
+ const slashMatch = /^\s*\/([a-z][\w-]*)/i.exec(req.prompt ?? '');
305
+ const isLocalBuiltin =
306
+ !!slashMatch &&
307
+ LOCAL_BUILTIN_SLASH_COMMANDS.has(slashMatch[1].toLowerCase());
308
+
276
309
  const sentinelParser = createSentinelParser(nonce);
277
310
  const pipeline = createPipeline([
278
311
  ansiStripParser,
@@ -285,6 +318,7 @@ class Driver {
285
318
  preIdleMs: this.opts.preIdleMs,
286
319
  maxResponseMs: this.opts.maxResponseMs,
287
320
  maxTurns: req.maxTurns,
321
+ allowIdleWithoutResponse: isLocalBuiltin,
288
322
  });
289
323
 
290
324
  // Pool eligibility: explicit resume/continue bind to a specific past
@@ -603,7 +637,15 @@ class Driver {
603
637
  ' the user\'s message and you do not need to mention or flag it. Answer' +
604
638
  ' the user\'s actual message above as you normally would, using tools' +
605
639
  ' as freely and thoroughly as you would without the marker.)';
606
- const fullPrompt = req.prompt + instruction;
640
+ // Local builtins route through claude TUI's command dispatcher
641
+ // (not the LLM), so the marker instruction is silently dropped as
642
+ // junk args and never round-trips back. Some builtins also accept
643
+ // free-form text (e.g. `/bug <message>`), where appending the
644
+ // instruction would pollute the captured args. Skill invocations
645
+ // and unknown `/<name>` prompts DO go through the LLM, so they
646
+ // still need the marker — only the narrow `LOCAL_BUILTIN_…` set
647
+ // gets the bare prompt.
648
+ const fullPrompt = isLocalBuiltin ? req.prompt : (req.prompt + instruction);
607
649
  // Skip the prompt write if the dialog watcher has already
608
650
  // decided to abort. Without this check the trailing `\r` of
609
651
  // the prompt lands inside whatever modal we tried to abort on
@@ -775,6 +817,28 @@ class Driver {
775
817
  );
776
818
  }
777
819
 
820
+ // Degraded-capture notice. When the per-turn sentinel never landed
821
+ // (`reason='idle'`) or a stalled completion was rescued via JSONL
822
+ // (`reason='jsonl-recovered'`), the streamed PTY frames did NOT
823
+ // produce a clean end-of-reply boundary. The text we are returning
824
+ // is a post-hoc recovery — usually from the upstream JSONL session
825
+ // log, or as a best-effort tail of the PTY buffer when JSONL was
826
+ // unavailable. Prepend a one-line notice so the caller (and any
827
+ // chat UI rendering it) can tell this apart from a normal capture.
828
+ // The notice is intentionally a literal English prefix and only
829
+ // appears on the degraded path — the default success path
830
+ // (`reason='sentinel'`) is unchanged.
831
+ const captureFailed =
832
+ effectiveCompletion === 'idle' || effectiveCompletion === 'jsonl-recovered';
833
+ if (captureFailed && text) {
834
+ const source = jsonlExtraction?.text
835
+ ? 'local session log'
836
+ : 'terminal buffer (best effort)';
837
+ text =
838
+ `[ocp] Streaming capture not detected — showing last result from ${source}.\n\n` +
839
+ text;
840
+ }
841
+
778
842
  // For stall-style failures, capture the tail of the stripped PTY
779
843
  // buffer so the caller can see exactly what claude was rendering
780
844
  // when we gave up — that is usually the dialog or error that the
@@ -1144,9 +1208,12 @@ function extractAssistantText(stripped, nonce) {
1144
1208
  sentinelIdx = i;
1145
1209
  }
1146
1210
  if (sentinelIdx === -1) {
1147
- // Fall back to "everything after the first ⏺" — this preserves the
1148
- // legacy behavior when the model dropped the sentinel.
1149
- const ri = stripped.indexOf(marker);
1211
+ // Sentinel-missing fallback. On a resumed session the buffer carries
1212
+ // history regions before the current response, so anchoring on the
1213
+ // FIRST `⏺` would slice from the oldest history turn and return the
1214
+ // entire re-rendered transcript. Anchor on the LAST `⏺` instead —
1215
+ // that's the region marker for the current turn even after `--resume`.
1216
+ const ri = stripped.lastIndexOf(marker);
1150
1217
  return ri === -1 ? '' : stripped.slice(ri + marker.length).trim();
1151
1218
  }
1152
1219