@halfwhey/claudraband 0.2.0 → 0.4.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 CHANGED
@@ -1,149 +1,161 @@
1
1
  <div align="center">
2
2
 
3
- # claudraband
3
+ # Claudraband
4
4
 
5
5
  Claude Code for the power user
6
6
 
7
- > Experimental: this project is still evolving and parts of it may break as Claude Code and ACP clients change.
7
+ > Experimental: this project is still evolving as Claude Code and ACP clients change.
8
8
 
9
- [Quick start](#quick-start) •
10
9
  [CLI](docs/cli.md) •
11
10
  [Library](docs/library.md) •
12
- [Examples](#examples) •
13
- [Troubleshooting](#troubleshooting)
11
+ [Daemon API](docs/daemon-api.md) •
12
+ [Examples](examples/)
14
13
 
15
14
  </div>
16
15
 
17
- `claudraband` wraps a Claude Code TUI in a controlled terminal to enable extended workflows. This project provides:
16
+ `claudraband` wraps the official Claude Code TUI in a controlled terminal so you can keep sessions alive, resume them later, answer pending prompts, expose them through a daemon, or drive them through ACP.
18
17
 
19
- - Resumable non-interactive workflows. Essentially `claude -p` with session support: `cband continue <session-id> 'what was the result of the research?'`
20
- - HTTP server to remotely control a Claude Code session: `cband serve --port 1234`
21
- - ACP server to use with alternative frontends such as Zed or Toad (https://github.com/batrachianai/toad): `cband acp --model haiku`
22
- - TypeScript library so you can integrate these workflows into your own application.
18
+ It provides:
23
19
 
24
- ## Caveats
20
+ - Resumable non-interactive workflows. Essentially claude -p with session support: cband continue <session-id> 'what was the result of the research?'
21
+ - An http daemon for remote or headless session control
22
+ - An ACP server for editor and alternate frontend integration
23
+ - A TypeScript library for building these workflows into your own tools
24
+
25
+ Caveats
25
26
 
26
27
  - This is not a replacement for the Claude SDK. It is geared toward personal, ad-hoc usage.
27
28
  - We do not touch OAuth and we do not bypass the Claude Code TUI. You must authenticate through Claude Code, and every interaction runs through a real Claude Code session.
28
29
 
29
- ## Requirements
30
+
31
+ ## Setup
32
+
33
+ Requirements:
30
34
 
31
35
  - Node.js or Bun
32
36
  - An already authenticated Claude Code
33
- - `tmux` if you want visible persistent local sessions
37
+ - `tmux` for the first-class local and daemon-backed workflow
34
38
 
35
- ## Install
39
+ Install or run:
36
40
 
37
41
  ```sh
38
- # run without installing globally
42
+ # one-off
39
43
  npx @halfwhey/claudraband "review the staged diff"
44
+ bunx @halfwhey/claudraband "review the staged diff"
40
45
 
41
- # or install it once
46
+ # install once
42
47
  npm install -g @halfwhey/claudraband
43
48
  ```
44
49
 
45
- If you prefer Bun:
46
-
47
- ```sh
48
- bunx @halfwhey/claudraband "review the staged diff"
49
- ```
50
-
51
- `claudraband` installs a pinned Claude Code version, `@anthropic-ai/claude-code@2.1.96`, as a dependency for compatibility. It will be bumped over time. If you need to point at a different Claude binary for debugging or compatibility work, set `CLAUDRABAND_CLAUDE_PATH`.
50
+ The package installs both `claudraband` and `cband`. `cband` is the recommended shorthand. The package bundles Claude Code `@anthropic-ai/claude-code@2.1.96`; set `CLAUDRABAND_CLAUDE_PATH` if you need to override the binary.
52
51
 
52
+ ## Quick Start
53
53
 
54
- ## Quick start
54
+ The two first-class paths are local `tmux` sessions and daemon-backed sessions.
55
55
 
56
- The package installs both `cband` and `claudraband`. The shorter `cband` binary is the recommended CLI. The two first-class ways to use `cband` are:
57
-
58
- - local persistent sessions with `tmux`
59
- - headless persistent sessions with `serve`
60
-
61
- ### Visible persistent sessions with `tmux`
56
+ ### Local persistent sessions
62
57
 
63
58
  ```sh
64
59
  cband "audit the last commit and tell me what looks risky"
65
-
66
60
  cband sessions
67
-
68
61
  cband continue <session-id> "keep going"
69
-
70
- # if Claude is waiting on a choice
71
62
  cband continue <session-id> --select 2
72
- cband continue <session-id> --select 3 "xyz"
73
63
  ```
74
64
 
75
- ### Headless persistent sessions with `serve`
65
+ ### Daemon-backed sessions
76
66
 
77
67
  ```sh
78
- cband serve --host 127.0.0.1 --backend xterm --port 7842
68
+ cband serve --host 127.0.0.1 --port 7842
79
69
  cband --connect localhost:7842 "start a migration plan"
80
70
  cband attach <session-id>
81
71
  cband continue <session-id> --select 2
82
72
  ```
83
73
 
84
- Use `--connect` only when starting a new daemon-backed session. After that, `continue`, `attach`, and `sessions` route through the tracked session automatically. `attach` is especially useful here because it gives you a simple REPL for a headless xterm.js session.
85
-
86
- ### Using the CLI without tmux or server
87
-
88
- If you run `cband "..."` without `tmux` and without `--connect`, `cband` falls back to a local headless `xterm.js` session. That mode is useful for one-off runs, but it is not a good default for interactive follow-up because the session is not kept alive between commands. It only works properly when Claude itself can proceed without an interactive permission prompt. Use one of:
74
+ The daemon defaults to using `tmux` as the terminal runtime, just like the local path. Use `--connect` only when creating a new daemon-backed session; after that, tracked `continue`, `attach`, and `sessions` route through the recorded live owner automatically.
89
75
 
90
- - `-c "--dangerously-skip-permissions"`
91
- - `--permission-mode bypassPermissions`
76
+ ## Experimental Xterm Backend
92
77
 
93
- Without `tmux` or server mode, `cband` shuts down Claude Code after each command finishes and starts it again on the next one. If the last output was a question for the user, that question will not survive well across the next resume. Interactive question flows work best with persistent sessions.
78
+ `--backend xterm` exists for local or daemon use, but it is experimental and slower than `tmux`. Use it when you need a headless fallback, not as the default path for long-lived interactive work. See [docs/cli.md](docs/cli.md) for current caveats and backend behavior.
94
79
 
95
- ### ACP and editor integration
80
+ ## ACP
96
81
 
97
82
  Use ACP when another tool wants to drive Claude through `claudraband`.
98
83
 
99
84
  ```sh
100
85
  cband acp --model opus
101
86
 
102
- # for example with toad
87
+ # example: toad
103
88
  uvx --from batrachian-toad toad acp 'cband acp -c "--model haiku"'
104
89
  ```
105
90
 
106
- Some ACP clients still have limitations around resuming existing sessions. `claudraband` itself supports session follow and resume as part of the ACP protocol, but the frontend you put on top may not expose all of that yet.
91
+ Editor and ACP client support varies by frontend, but `claudraband` itself supports session follow and resume through ACP.
107
92
 
108
- ## Session model
93
+ ## Session Model
109
94
 
110
95
  Live sessions are tracked in `~/.claudraband/`.
111
96
 
112
- - `cband sessions` shows only live tracked sessions, either hosted by tmux or the xterm.js daemon.
113
- - `continue` can resume an existing Claude Code session even when it is no longer live, but `attach` only works on live sessions.
114
- - `sessions close ...` closes live sessions hosted by tmux or the xterm.js daemon
115
- - `sessions close --all` will close all live sessions controlled by Claudraband
97
+ - `cband sessions` lists live tracked sessions
98
+ - `continue` can resume an existing Claude Code session even when it is no longer live
99
+ - `attach` only works on live sessions
100
+ - `sessions close ...` closes live tracked sessions, either local or daemon-backed
116
101
 
117
102
  ## Examples
118
103
 
119
104
  ### Self-interrogation
120
105
 
121
- I have a Claude Code hook that saves the session id that was involved in a commit so I can ask it questions about the commit later. In this workflow, Claude can use `claudraband` to interrogate that older session and justify the choices it made.
106
+ Claude can interrogate an older Claude session and justify the choices it made.
122
107
 
123
108
  ![Claude interrogating an older Claude session through claudraband](assets/self-interrogate.png)
124
109
 
125
110
  ### Toad via ACP
126
111
 
127
- Toad can use `claudraband acp` to be an alternative frontend for Claude Code.
112
+ Toad can use `claudraband acp` as an alternative frontend for Claude Code.
128
113
 
129
114
  ![Toad using claudraband ACP as an alternative frontend](assets/toad-acp.png)
130
115
 
131
- That UI is backed by a real Claude Code pane underneath.
116
+ That UI is still backed by a real Claude Code pane underneath.
132
117
 
133
118
  ![Backing Claude Code pane for the Toad ACP session](assets/toad-claude-pane.png)
134
119
 
135
120
  ### Zed via ACP
136
121
 
137
- Zed can also use `claudraband acp` to be an alternative frontend.
122
+ Zed can also use `claudraband acp` as an alternative frontend.
138
123
 
139
124
  ![Zed using claudraband ACP as an alternative frontend](assets/zed-acp.png)
140
125
 
141
- ## Library use
126
+ ## Library
142
127
 
143
- Runnable TypeScript examples live in [`examples/`](examples):
128
+ Runnable TypeScript examples live in [`examples/`](examples/):
144
129
 
145
- - [`examples/code-review.ts`](examples/code-review.ts) starts a session, asks for a review, and prints the result
146
- - [`examples/multi-session.ts`](examples/multi-session.ts) runs multiple Claude sessions in parallel
147
- - [`examples/session-journal.ts`](examples/session-journal.ts) resumes a session and writes a simple journal
130
+ - [`examples/code-review.ts`](examples/code-review.ts)
131
+ - [`examples/multi-session.ts`](examples/multi-session.ts)
132
+ - [`examples/session-journal.ts`](examples/session-journal.ts)
148
133
 
149
- For the full TypeScript API, see [docs/library.md](docs/library.md).
134
+ For the full API, see [docs/library.md](docs/library.md). For CLI details, see [docs/cli.md](docs/cli.md). For raw daemon endpoints, see [docs/daemon-api.md](docs/daemon-api.md).
135
+
136
+ ## Cheat Sheet
137
+
138
+ ```sh
139
+ # install or run once
140
+ npx @halfwhey/claudraband "review the staged diff"
141
+ bunx @halfwhey/claudraband "review the staged diff"
142
+ npm install -g @halfwhey/claudraband
143
+
144
+ # local persistent sessions
145
+ cband "audit the last commit"
146
+ cband sessions
147
+ cband sessions close --all # close all claudraband controlled sessions
148
+ cband continue <session-id> "keep going"
149
+
150
+ # answer pending prompts
151
+ cband continue <session-id> --select 2
152
+ cband continue <session-id> --select 3 "xyz"
153
+
154
+ # daemon mode
155
+ cband serve --host 127.0.0.1 --port 7842
156
+ cband --connect localhost:7842 "start a migration plan"
157
+ cband attach <session-id>
158
+
159
+ # ACP
160
+ cband acp --model opus
161
+ ```
package/dist/bin.js CHANGED
@@ -5443,6 +5443,9 @@ class ClaudeWrapper {
5443
5443
  if (pane.includes("INSERT") || pane.includes("NORMAL")) {
5444
5444
  return;
5445
5445
  }
5446
+ if (pane.includes("Yes, I trust this folder") && pane.includes("No, exit") || pane.includes("Bypass Permissions mode") && pane.includes("Yes, I accept")) {
5447
+ return;
5448
+ }
5446
5449
  } catch {}
5447
5450
  await new Promise((r) => setTimeout(r, POLL_MS));
5448
5451
  }
@@ -5531,6 +5534,9 @@ var init_claude = __esm(() => {
5531
5534
 
5532
5535
  // ../claudraband-core/src/claude/inspect.ts
5533
5536
  import { readFile } from "node:fs/promises";
5537
+ function normalizeCapturedPane(paneText) {
5538
+ return paneText.replace(/\r/g, "").replace(/\x1b\[(\d+)C/g, (_match, count) => " ".repeat(Number.parseInt(count, 10) || 0)).replace(/\x1b\[[0-9;]*[ABDHJKf]/g, "").replace(/\x1b\[\?[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\[[0-9;]*m/g, "").replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
5539
+ }
5534
5540
  async function hasPendingQuestion(jsonlPath) {
5535
5541
  let data;
5536
5542
  try {
@@ -5564,10 +5570,20 @@ async function hasPendingQuestion(jsonlPath) {
5564
5570
  return pendingIds.size > 0;
5565
5571
  }
5566
5572
  function parseNativePermissionPrompt(paneText) {
5567
- const questionMatch = paneText.match(/(?:^|\n)\s*(Do you want to [^\n]+\?)/);
5573
+ const normalizedPane = normalizeCapturedPane(paneText);
5574
+ const isTrustPrompt = normalizedPane.includes("Yes, I trust this folder") && normalizedPane.includes("No, exit");
5575
+ const isBypassPrompt = normalizedPane.includes("Bypass Permissions mode") && normalizedPane.includes("Yes, I accept");
5576
+ let questionMatch;
5577
+ if (isTrustPrompt) {
5578
+ questionMatch = normalizedPane.match(/(Is this a project you created[^\n]*)/);
5579
+ } else if (isBypassPrompt) {
5580
+ questionMatch = normalizedPane.match(/(you accept all responsibility[^\n]*)/);
5581
+ } else {
5582
+ questionMatch = normalizedPane.match(/(?:^|\n)\s*(Do you want to [^\n]+\?)/);
5583
+ }
5568
5584
  if (!questionMatch)
5569
5585
  return null;
5570
- const afterQuestion = paneText.slice(paneText.indexOf(questionMatch[1]) + questionMatch[1].length);
5586
+ const afterQuestion = normalizedPane.slice(normalizedPane.indexOf(questionMatch[1]) + questionMatch[1].length);
5571
5587
  const optionRegex = /(?:❯\s*)?(\d+)\.\s+(.+)/g;
5572
5588
  const options = [];
5573
5589
  let match;
@@ -5699,6 +5715,10 @@ var init_session_registry = () => {};
5699
5715
  // ../claudraband-core/src/index.ts
5700
5716
  import { open as open2, readFile as readFile3, stat as stat2 } from "node:fs/promises";
5701
5717
  import { request as httpRequest } from "node:http";
5718
+ function isRejectingNativeOption(label) {
5719
+ const normalized = label.trim().toLowerCase();
5720
+ return normalized.startsWith("no") || normalized.startsWith("reject");
5721
+ }
5702
5722
  function makeDefaultLogger() {
5703
5723
  const noop = () => {};
5704
5724
  return {
@@ -5820,6 +5840,8 @@ class ClaudrabandSessionImpl {
5820
5840
  _permissionMode;
5821
5841
  allowTextResponses;
5822
5842
  sessionOwner;
5843
+ lastNativePermissionFingerprint = null;
5844
+ lastNativePermissionOutcome = "none";
5823
5845
  constructor(wrapper, sessionId, cwd, backend, model, permissionMode, allowTextResponses, logger, onPermissionRequest, lifetime, sessionOwner) {
5824
5846
  this.sessionId = sessionId;
5825
5847
  this.cwd = cwd;
@@ -5854,7 +5876,20 @@ class ClaudrabandSessionImpl {
5854
5876
  const prompt = this.newPromptWaiter(text);
5855
5877
  this.activePrompt = prompt;
5856
5878
  this.logger.info("prompt received", "sid", this.sessionId, "length", text.length);
5857
- await this.wrapper.send(text);
5879
+ const startupState = await this.prepareForInput(text);
5880
+ if (startupState === "blocked") {
5881
+ this.activePrompt = null;
5882
+ this.promptAbortController = null;
5883
+ return { stopReason: "end_turn" };
5884
+ }
5885
+ if (startupState === "cancelled") {
5886
+ this.activePrompt = null;
5887
+ this.promptAbortController = null;
5888
+ return { stopReason: "cancelled" };
5889
+ }
5890
+ if (startupState !== "consumed") {
5891
+ await this.wrapper.send(text);
5892
+ }
5858
5893
  try {
5859
5894
  const stopReason = await this.waitForPromptCompletion(prompt, controller.signal);
5860
5895
  this.logger.info("prompt completed", "sid", this.sessionId, "stop_reason", stopReason);
@@ -5877,6 +5912,17 @@ class ClaudrabandSessionImpl {
5877
5912
  const prompt = this.newPromptWaiter("");
5878
5913
  prompt.matchedUserEcho = true;
5879
5914
  this.activePrompt = prompt;
5915
+ const startupState = await this.prepareForInput(null);
5916
+ if (startupState === "blocked") {
5917
+ this.activePrompt = null;
5918
+ this.promptAbortController = null;
5919
+ return { stopReason: "end_turn" };
5920
+ }
5921
+ if (startupState === "cancelled") {
5922
+ this.activePrompt = null;
5923
+ this.promptAbortController = null;
5924
+ return { stopReason: "cancelled" };
5925
+ }
5880
5926
  this.logger.info("awaitTurn", "sid", this.sessionId);
5881
5927
  try {
5882
5928
  const stopReason = await this.waitForPromptCompletion(prompt, controller.signal);
@@ -5900,6 +5946,26 @@ class ClaudrabandSessionImpl {
5900
5946
  const prompt = this.newPromptWaiter("");
5901
5947
  prompt.matchedUserEcho = true;
5902
5948
  this.activePrompt = prompt;
5949
+ const startupState = await this.prepareForInput(text);
5950
+ if (startupState === "blocked") {
5951
+ this.activePrompt = null;
5952
+ this.promptAbortController = null;
5953
+ return { stopReason: "end_turn" };
5954
+ }
5955
+ if (startupState === "cancelled") {
5956
+ this.activePrompt = null;
5957
+ this.promptAbortController = null;
5958
+ return { stopReason: "cancelled" };
5959
+ }
5960
+ if (startupState === "consumed") {
5961
+ if (this.activePrompt === prompt) {
5962
+ this.activePrompt = null;
5963
+ }
5964
+ if (this.promptAbortController === controller) {
5965
+ this.promptAbortController = null;
5966
+ }
5967
+ return { stopReason: "end_turn" };
5968
+ }
5903
5969
  this.logger.info("sendAndAwaitTurn", "sid", this.sessionId, "length", text.length);
5904
5970
  await this.wrapper.send(text);
5905
5971
  try {
@@ -5918,6 +5984,21 @@ class ClaudrabandSessionImpl {
5918
5984
  send(text) {
5919
5985
  return this.wrapper.send(text);
5920
5986
  }
5987
+ async prepareForInput(intendedText) {
5988
+ const handled = await this.pollNativePermission(null, intendedText);
5989
+ if (handled === "handled" || handled === "consumed" || handled === "pending_clear") {
5990
+ const ready = await this.waitForInsertMode();
5991
+ if (!ready) {
5992
+ return this.wrapper.isProcessAlive() ? "blocked" : "cancelled";
5993
+ }
5994
+ return handled === "consumed" ? "consumed" : "ready";
5995
+ }
5996
+ const stillBlocking = hasPendingNativePrompt(await this.wrapper.capturePane().catch(() => ""));
5997
+ if (stillBlocking) {
5998
+ return "blocked";
5999
+ }
6000
+ return "ready";
6001
+ }
5921
6002
  async interrupt() {
5922
6003
  this.promptAbortController?.abort();
5923
6004
  await this.wrapper.interrupt();
@@ -6237,7 +6318,7 @@ class ClaudrabandSessionImpl {
6237
6318
  }
6238
6319
  if (prompt.pendingTools > 0) {
6239
6320
  const handled = await this.pollNativePermission(prompt.lastPendingTool);
6240
- if (handled) {
6321
+ if (handled === "handled" || handled === "consumed") {
6241
6322
  prompt.pendingTools = Math.max(0, prompt.pendingTools - 1);
6242
6323
  prompt.gotResponse = true;
6243
6324
  prompt.lastPendingTool = null;
@@ -6252,16 +6333,30 @@ class ClaudrabandSessionImpl {
6252
6333
  }
6253
6334
  }
6254
6335
  }
6255
- async pollNativePermission(pendingTool) {
6336
+ async pollNativePermission(pendingTool, intendedText = null) {
6256
6337
  let paneText;
6257
6338
  try {
6258
6339
  paneText = await this.wrapper.capturePane();
6259
6340
  } catch {
6260
- return false;
6341
+ return "none";
6261
6342
  }
6262
6343
  const prompt = parseNativePermissionPrompt(paneText);
6263
- if (!prompt)
6264
- return false;
6344
+ if (!prompt) {
6345
+ this.lastNativePermissionFingerprint = null;
6346
+ this.lastNativePermissionOutcome = "none";
6347
+ return "none";
6348
+ }
6349
+ const fingerprint = `${prompt.question}
6350
+ ${prompt.options.map((option) => `${option.number}:${option.label}`).join(`
6351
+ `)}`;
6352
+ if (this.lastNativePermissionFingerprint === fingerprint) {
6353
+ if (this.lastNativePermissionOutcome === "handled" || this.lastNativePermissionOutcome === "consumed") {
6354
+ return "pending_clear";
6355
+ }
6356
+ if (this.lastNativePermissionOutcome !== "deferred") {
6357
+ return this.lastNativePermissionOutcome;
6358
+ }
6359
+ }
6265
6360
  const decision = await this.resolvePermission({
6266
6361
  source: "native_prompt",
6267
6362
  sessionId: this.sessionId,
@@ -6281,19 +6376,62 @@ class ClaudrabandSessionImpl {
6281
6376
  name: opt.label
6282
6377
  }))
6283
6378
  });
6379
+ let outcome;
6284
6380
  if (decision.outcome === "deferred") {
6285
- return false;
6381
+ outcome = "deferred";
6382
+ } else if (decision.outcome === "cancelled") {
6383
+ await this.safeInterruptNativePermission();
6384
+ outcome = "handled";
6385
+ } else if (decision.outcome === "text") {
6386
+ await this.safeSendNativePermission(decision.text, false);
6387
+ outcome = decision.text === intendedText ? "consumed" : "handled";
6388
+ } else {
6389
+ const selected = prompt.options.find((option) => option.number === decision.optionId);
6390
+ const tolerateExit = selected ? isRejectingNativeOption(selected.label) : false;
6391
+ await this.safeSendNativePermission(decision.optionId, tolerateExit);
6392
+ outcome = decision.optionId === intendedText ? "consumed" : "handled";
6286
6393
  }
6287
- if (decision.outcome === "cancelled") {
6394
+ this.lastNativePermissionFingerprint = fingerprint;
6395
+ this.lastNativePermissionOutcome = outcome;
6396
+ return outcome;
6397
+ }
6398
+ async safeSendNativePermission(input, tolerateExit) {
6399
+ try {
6400
+ await this.wrapper.send(input);
6401
+ } catch (error) {
6402
+ if (tolerateExit && !this.wrapper.isProcessAlive()) {
6403
+ this.logger.debug("native permission send ignored after pane exit", "sid", this.sessionId);
6404
+ return;
6405
+ }
6406
+ throw error;
6407
+ }
6408
+ }
6409
+ async safeInterruptNativePermission() {
6410
+ try {
6288
6411
  await this.wrapper.interrupt();
6289
- return true;
6412
+ } catch (error) {
6413
+ if (!this.wrapper.isProcessAlive()) {
6414
+ this.logger.debug("native permission interrupt ignored after pane exit", "sid", this.sessionId);
6415
+ return;
6416
+ }
6417
+ throw error;
6290
6418
  }
6291
- if (decision.outcome === "text") {
6292
- await this.wrapper.send(decision.text);
6293
- return true;
6419
+ }
6420
+ async waitForInsertMode(timeoutMs = 15000) {
6421
+ const POLL_MS = 300;
6422
+ const start = Date.now();
6423
+ while (Date.now() - start < timeoutMs) {
6424
+ if (!this.wrapper.isProcessAlive()) {
6425
+ return false;
6426
+ }
6427
+ try {
6428
+ const pane = await this.wrapper.capturePane();
6429
+ if (pane.includes("INSERT") || pane.includes("NORMAL"))
6430
+ return true;
6431
+ } catch {}
6432
+ await new Promise((r) => setTimeout(r, POLL_MS));
6294
6433
  }
6295
- await this.wrapper.send(decision.optionId);
6296
- return true;
6434
+ return false;
6297
6435
  }
6298
6436
  async handleUserQuestion(ev) {
6299
6437
  const parsed = parseAskUserQuestion(ev.toolInput);
@@ -6812,16 +6950,21 @@ var init_src = __esm(() => {
6812
6950
 
6813
6951
  // src/client.ts
6814
6952
  import { createInterface } from "node:readline/promises";
6953
+ function formatOptionLabel(name, kind) {
6954
+ const suffix = `(${kind})`;
6955
+ return name.trim().endsWith(suffix) ? name.trim() : `${name} (${kind})`;
6956
+ }
6815
6957
  async function requestPermission(renderer, config, params) {
6816
6958
  renderer.ensureNewline();
6817
6959
  process.stderr.write(`\x1B[33mPermission: ${params.title}\x1B[0m
6818
6960
  `);
6819
- for (const block of params.content) {
6961
+ const contentBlocks = params.content.length === 1 && params.content[0]?.text.trim() === params.title.trim() ? [] : params.content;
6962
+ for (const block of contentBlocks) {
6820
6963
  process.stderr.write(`${block.text}
6821
6964
  `);
6822
6965
  }
6823
6966
  for (const option of params.options) {
6824
- process.stderr.write(` ${option.optionId}. ${option.name} (${option.kind})
6967
+ process.stderr.write(` ${option.optionId}. ${formatOptionLabel(option.name, option.kind)}
6825
6968
  `);
6826
6969
  }
6827
6970
  if (config.answerChoice) {
@@ -6887,7 +7030,7 @@ function shouldReuseSession(ds) {
6887
7030
  return ds !== null && ds.session.isProcessAlive();
6888
7031
  }
6889
7032
  function resolveServerTerminalBackend(config) {
6890
- return config.hasExplicitTerminalBackend ? config.terminalBackend : "xterm";
7033
+ return config.hasExplicitTerminalBackend ? config.terminalBackend : "tmux";
6891
7034
  }
6892
7035
  function formatHostForUrl(host) {
6893
7036
  return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
@@ -6982,19 +7125,29 @@ function createDaemonServer(config, runtime, logger) {
6982
7125
  if (method === "POST" && path === "/sessions") {
6983
7126
  const body = JSON.parse(await readBody(req));
6984
7127
  const sessionConfig = resolveSessionConfig(config, body);
6985
- const session = await runtime.startSession({
7128
+ const sessionOwner = {
7129
+ kind: "daemon",
7130
+ serverUrl,
7131
+ serverPid: process.pid,
7132
+ serverInstanceId
7133
+ };
7134
+ const session = body.sessionId ? await runtime.resumeSession(body.sessionId, {
6986
7135
  cwd: sessionConfig.cwd,
6987
7136
  claudeArgs: sessionConfig.claudeArgs,
6988
7137
  model: sessionConfig.model,
6989
7138
  permissionMode: sessionConfig.permissionMode,
6990
7139
  allowTextResponses: true,
6991
7140
  logger,
6992
- sessionOwner: {
6993
- kind: "daemon",
6994
- serverUrl,
6995
- serverPid: process.pid,
6996
- serverInstanceId
6997
- },
7141
+ sessionOwner,
7142
+ onPermissionRequest: (request) => handlePermission(ds2, request)
7143
+ }) : await runtime.startSession({
7144
+ cwd: sessionConfig.cwd,
7145
+ claudeArgs: sessionConfig.claudeArgs,
7146
+ model: sessionConfig.model,
7147
+ permissionMode: sessionConfig.permissionMode,
7148
+ allowTextResponses: true,
7149
+ logger,
7150
+ sessionOwner,
6998
7151
  onPermissionRequest: (request) => handlePermission(ds2, request)
6999
7152
  });
7000
7153
  const ds2 = {
@@ -7302,6 +7455,7 @@ __export(exports_daemon_client, {
7302
7455
  __test: () => __test2
7303
7456
  });
7304
7457
  import { request as httpRequest2 } from "node:http";
7458
+ import { randomUUID as randomUUID3 } from "node:crypto";
7305
7459
  function daemonUrl(server, path) {
7306
7460
  const base = server.startsWith("http") ? server : `http://${server}`;
7307
7461
  return `${base}${path}`;
@@ -7352,6 +7506,7 @@ async function daemonRequest2(server, method, path, body) {
7352
7506
  }
7353
7507
  function buildSessionRequestBody(config, options = {}) {
7354
7508
  return {
7509
+ ...options.sessionId ? { sessionId: options.sessionId } : {},
7355
7510
  cwd: config.cwd,
7356
7511
  ...config.hasExplicitClaudeArgs ? { claudeArgs: config.claudeArgs } : {},
7357
7512
  ...config.hasExplicitModel ? { model: config.model } : {},
@@ -7603,13 +7758,14 @@ async function runWithDaemon(config, renderer, _logger) {
7603
7758
  sessionId = config.sessionId;
7604
7759
  sessionBackend = result.backend;
7605
7760
  } else {
7761
+ sessionId = randomUUID3();
7762
+ process.stderr.write(`session: ${sessionId}
7763
+ `);
7606
7764
  const result = await daemonPost(config.connect, "/sessions", {
7607
- ...buildSessionRequestBody(config)
7765
+ ...buildSessionRequestBody(config, { sessionId })
7608
7766
  });
7609
7767
  sessionId = result.sessionId;
7610
7768
  sessionBackend = result.backend;
7611
- process.stderr.write(`session: ${sessionId}
7612
- `);
7613
7769
  }
7614
7770
  const session = new DaemonSessionProxy(config.connect, sessionId, config.cwd, sessionBackend, config.model, config.permissionMode, permissionHandler);
7615
7771
  await session.startEventStream();
@@ -41,6 +41,9 @@ export declare class ClaudeWrapper implements Wrapper {
41
41
  /**
42
42
  * Poll the terminal until Claude Code is ready to accept input.
43
43
  * Looks for "INSERT" in the status bar, which indicates the TUI has loaded.
44
+ *
45
+ * Also handles the "trust this folder" prompt that appears before any JSONL
46
+ * is written when Claude Code runs in a directory for the first time.
44
47
  */
45
48
  private waitForReady;
46
49
  stop(): Promise<void>;
package/dist/index.js CHANGED
@@ -5455,6 +5455,9 @@ class ClaudeWrapper {
5455
5455
  if (pane.includes("INSERT") || pane.includes("NORMAL")) {
5456
5456
  return;
5457
5457
  }
5458
+ if (pane.includes("Yes, I trust this folder") && pane.includes("No, exit") || pane.includes("Bypass Permissions mode") && pane.includes("Yes, I accept")) {
5459
+ return;
5460
+ }
5458
5461
  } catch {}
5459
5462
  await new Promise((r) => setTimeout(r, POLL_MS));
5460
5463
  }
@@ -5537,6 +5540,9 @@ function parseClaudeArgs(args) {
5537
5540
  }
5538
5541
  // ../claudraband-core/src/claude/inspect.ts
5539
5542
  import { readFile } from "node:fs/promises";
5543
+ function normalizeCapturedPane(paneText) {
5544
+ return paneText.replace(/\r/g, "").replace(/\x1b\[(\d+)C/g, (_match, count) => " ".repeat(Number.parseInt(count, 10) || 0)).replace(/\x1b\[[0-9;]*[ABDHJKf]/g, "").replace(/\x1b\[\?[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\[[0-9;]*m/g, "").replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
5545
+ }
5540
5546
  async function hasPendingQuestion(jsonlPath) {
5541
5547
  let data;
5542
5548
  try {
@@ -5570,10 +5576,20 @@ async function hasPendingQuestion(jsonlPath) {
5570
5576
  return pendingIds.size > 0;
5571
5577
  }
5572
5578
  function parseNativePermissionPrompt(paneText) {
5573
- const questionMatch = paneText.match(/(?:^|\n)\s*(Do you want to [^\n]+\?)/);
5579
+ const normalizedPane = normalizeCapturedPane(paneText);
5580
+ const isTrustPrompt = normalizedPane.includes("Yes, I trust this folder") && normalizedPane.includes("No, exit");
5581
+ const isBypassPrompt = normalizedPane.includes("Bypass Permissions mode") && normalizedPane.includes("Yes, I accept");
5582
+ let questionMatch;
5583
+ if (isTrustPrompt) {
5584
+ questionMatch = normalizedPane.match(/(Is this a project you created[^\n]*)/);
5585
+ } else if (isBypassPrompt) {
5586
+ questionMatch = normalizedPane.match(/(you accept all responsibility[^\n]*)/);
5587
+ } else {
5588
+ questionMatch = normalizedPane.match(/(?:^|\n)\s*(Do you want to [^\n]+\?)/);
5589
+ }
5574
5590
  if (!questionMatch)
5575
5591
  return null;
5576
- const afterQuestion = paneText.slice(paneText.indexOf(questionMatch[1]) + questionMatch[1].length);
5592
+ const afterQuestion = normalizedPane.slice(normalizedPane.indexOf(questionMatch[1]) + questionMatch[1].length);
5577
5593
  const optionRegex = /(?:❯\s*)?(\d+)\.\s+(.+)/g;
5578
5594
  const options = [];
5579
5595
  let match;
@@ -5748,6 +5764,10 @@ var TERMINAL_BACKENDS = [
5748
5764
  description: "Run Claude Code in a headless xterm-backed PTY"
5749
5765
  }
5750
5766
  ];
5767
+ function isRejectingNativeOption(label) {
5768
+ const normalized = label.trim().toLowerCase();
5769
+ return normalized.startsWith("no") || normalized.startsWith("reject");
5770
+ }
5751
5771
  function makeDefaultLogger() {
5752
5772
  const noop = () => {};
5753
5773
  return {
@@ -5869,6 +5889,8 @@ class ClaudrabandSessionImpl {
5869
5889
  _permissionMode;
5870
5890
  allowTextResponses;
5871
5891
  sessionOwner;
5892
+ lastNativePermissionFingerprint = null;
5893
+ lastNativePermissionOutcome = "none";
5872
5894
  constructor(wrapper, sessionId, cwd, backend, model, permissionMode, allowTextResponses, logger, onPermissionRequest, lifetime, sessionOwner) {
5873
5895
  this.sessionId = sessionId;
5874
5896
  this.cwd = cwd;
@@ -5903,7 +5925,20 @@ class ClaudrabandSessionImpl {
5903
5925
  const prompt = this.newPromptWaiter(text);
5904
5926
  this.activePrompt = prompt;
5905
5927
  this.logger.info("prompt received", "sid", this.sessionId, "length", text.length);
5906
- await this.wrapper.send(text);
5928
+ const startupState = await this.prepareForInput(text);
5929
+ if (startupState === "blocked") {
5930
+ this.activePrompt = null;
5931
+ this.promptAbortController = null;
5932
+ return { stopReason: "end_turn" };
5933
+ }
5934
+ if (startupState === "cancelled") {
5935
+ this.activePrompt = null;
5936
+ this.promptAbortController = null;
5937
+ return { stopReason: "cancelled" };
5938
+ }
5939
+ if (startupState !== "consumed") {
5940
+ await this.wrapper.send(text);
5941
+ }
5907
5942
  try {
5908
5943
  const stopReason = await this.waitForPromptCompletion(prompt, controller.signal);
5909
5944
  this.logger.info("prompt completed", "sid", this.sessionId, "stop_reason", stopReason);
@@ -5926,6 +5961,17 @@ class ClaudrabandSessionImpl {
5926
5961
  const prompt = this.newPromptWaiter("");
5927
5962
  prompt.matchedUserEcho = true;
5928
5963
  this.activePrompt = prompt;
5964
+ const startupState = await this.prepareForInput(null);
5965
+ if (startupState === "blocked") {
5966
+ this.activePrompt = null;
5967
+ this.promptAbortController = null;
5968
+ return { stopReason: "end_turn" };
5969
+ }
5970
+ if (startupState === "cancelled") {
5971
+ this.activePrompt = null;
5972
+ this.promptAbortController = null;
5973
+ return { stopReason: "cancelled" };
5974
+ }
5929
5975
  this.logger.info("awaitTurn", "sid", this.sessionId);
5930
5976
  try {
5931
5977
  const stopReason = await this.waitForPromptCompletion(prompt, controller.signal);
@@ -5949,6 +5995,26 @@ class ClaudrabandSessionImpl {
5949
5995
  const prompt = this.newPromptWaiter("");
5950
5996
  prompt.matchedUserEcho = true;
5951
5997
  this.activePrompt = prompt;
5998
+ const startupState = await this.prepareForInput(text);
5999
+ if (startupState === "blocked") {
6000
+ this.activePrompt = null;
6001
+ this.promptAbortController = null;
6002
+ return { stopReason: "end_turn" };
6003
+ }
6004
+ if (startupState === "cancelled") {
6005
+ this.activePrompt = null;
6006
+ this.promptAbortController = null;
6007
+ return { stopReason: "cancelled" };
6008
+ }
6009
+ if (startupState === "consumed") {
6010
+ if (this.activePrompt === prompt) {
6011
+ this.activePrompt = null;
6012
+ }
6013
+ if (this.promptAbortController === controller) {
6014
+ this.promptAbortController = null;
6015
+ }
6016
+ return { stopReason: "end_turn" };
6017
+ }
5952
6018
  this.logger.info("sendAndAwaitTurn", "sid", this.sessionId, "length", text.length);
5953
6019
  await this.wrapper.send(text);
5954
6020
  try {
@@ -5967,6 +6033,21 @@ class ClaudrabandSessionImpl {
5967
6033
  send(text) {
5968
6034
  return this.wrapper.send(text);
5969
6035
  }
6036
+ async prepareForInput(intendedText) {
6037
+ const handled = await this.pollNativePermission(null, intendedText);
6038
+ if (handled === "handled" || handled === "consumed" || handled === "pending_clear") {
6039
+ const ready = await this.waitForInsertMode();
6040
+ if (!ready) {
6041
+ return this.wrapper.isProcessAlive() ? "blocked" : "cancelled";
6042
+ }
6043
+ return handled === "consumed" ? "consumed" : "ready";
6044
+ }
6045
+ const stillBlocking = hasPendingNativePrompt(await this.wrapper.capturePane().catch(() => ""));
6046
+ if (stillBlocking) {
6047
+ return "blocked";
6048
+ }
6049
+ return "ready";
6050
+ }
5970
6051
  async interrupt() {
5971
6052
  this.promptAbortController?.abort();
5972
6053
  await this.wrapper.interrupt();
@@ -6286,7 +6367,7 @@ class ClaudrabandSessionImpl {
6286
6367
  }
6287
6368
  if (prompt.pendingTools > 0) {
6288
6369
  const handled = await this.pollNativePermission(prompt.lastPendingTool);
6289
- if (handled) {
6370
+ if (handled === "handled" || handled === "consumed") {
6290
6371
  prompt.pendingTools = Math.max(0, prompt.pendingTools - 1);
6291
6372
  prompt.gotResponse = true;
6292
6373
  prompt.lastPendingTool = null;
@@ -6301,16 +6382,30 @@ class ClaudrabandSessionImpl {
6301
6382
  }
6302
6383
  }
6303
6384
  }
6304
- async pollNativePermission(pendingTool) {
6385
+ async pollNativePermission(pendingTool, intendedText = null) {
6305
6386
  let paneText;
6306
6387
  try {
6307
6388
  paneText = await this.wrapper.capturePane();
6308
6389
  } catch {
6309
- return false;
6390
+ return "none";
6310
6391
  }
6311
6392
  const prompt = parseNativePermissionPrompt(paneText);
6312
- if (!prompt)
6313
- return false;
6393
+ if (!prompt) {
6394
+ this.lastNativePermissionFingerprint = null;
6395
+ this.lastNativePermissionOutcome = "none";
6396
+ return "none";
6397
+ }
6398
+ const fingerprint = `${prompt.question}
6399
+ ${prompt.options.map((option) => `${option.number}:${option.label}`).join(`
6400
+ `)}`;
6401
+ if (this.lastNativePermissionFingerprint === fingerprint) {
6402
+ if (this.lastNativePermissionOutcome === "handled" || this.lastNativePermissionOutcome === "consumed") {
6403
+ return "pending_clear";
6404
+ }
6405
+ if (this.lastNativePermissionOutcome !== "deferred") {
6406
+ return this.lastNativePermissionOutcome;
6407
+ }
6408
+ }
6314
6409
  const decision = await this.resolvePermission({
6315
6410
  source: "native_prompt",
6316
6411
  sessionId: this.sessionId,
@@ -6330,19 +6425,62 @@ class ClaudrabandSessionImpl {
6330
6425
  name: opt.label
6331
6426
  }))
6332
6427
  });
6428
+ let outcome;
6333
6429
  if (decision.outcome === "deferred") {
6334
- return false;
6430
+ outcome = "deferred";
6431
+ } else if (decision.outcome === "cancelled") {
6432
+ await this.safeInterruptNativePermission();
6433
+ outcome = "handled";
6434
+ } else if (decision.outcome === "text") {
6435
+ await this.safeSendNativePermission(decision.text, false);
6436
+ outcome = decision.text === intendedText ? "consumed" : "handled";
6437
+ } else {
6438
+ const selected = prompt.options.find((option) => option.number === decision.optionId);
6439
+ const tolerateExit = selected ? isRejectingNativeOption(selected.label) : false;
6440
+ await this.safeSendNativePermission(decision.optionId, tolerateExit);
6441
+ outcome = decision.optionId === intendedText ? "consumed" : "handled";
6442
+ }
6443
+ this.lastNativePermissionFingerprint = fingerprint;
6444
+ this.lastNativePermissionOutcome = outcome;
6445
+ return outcome;
6446
+ }
6447
+ async safeSendNativePermission(input, tolerateExit) {
6448
+ try {
6449
+ await this.wrapper.send(input);
6450
+ } catch (error) {
6451
+ if (tolerateExit && !this.wrapper.isProcessAlive()) {
6452
+ this.logger.debug("native permission send ignored after pane exit", "sid", this.sessionId);
6453
+ return;
6454
+ }
6455
+ throw error;
6335
6456
  }
6336
- if (decision.outcome === "cancelled") {
6457
+ }
6458
+ async safeInterruptNativePermission() {
6459
+ try {
6337
6460
  await this.wrapper.interrupt();
6338
- return true;
6461
+ } catch (error) {
6462
+ if (!this.wrapper.isProcessAlive()) {
6463
+ this.logger.debug("native permission interrupt ignored after pane exit", "sid", this.sessionId);
6464
+ return;
6465
+ }
6466
+ throw error;
6339
6467
  }
6340
- if (decision.outcome === "text") {
6341
- await this.wrapper.send(decision.text);
6342
- return true;
6468
+ }
6469
+ async waitForInsertMode(timeoutMs = 15000) {
6470
+ const POLL_MS = 300;
6471
+ const start = Date.now();
6472
+ while (Date.now() - start < timeoutMs) {
6473
+ if (!this.wrapper.isProcessAlive()) {
6474
+ return false;
6475
+ }
6476
+ try {
6477
+ const pane = await this.wrapper.capturePane();
6478
+ if (pane.includes("INSERT") || pane.includes("NORMAL"))
6479
+ return true;
6480
+ } catch {}
6481
+ await new Promise((r) => setTimeout(r, POLL_MS));
6343
6482
  }
6344
- await this.wrapper.send(decision.optionId);
6345
- return true;
6483
+ return false;
6346
6484
  }
6347
6485
  async handleUserQuestion(ev) {
6348
6486
  const parsed = parseAskUserQuestion(ev.toolInput);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@halfwhey/claudraband",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Control the official Claude Code CLI programmatically. Use as a library, a direct CLI, an ACP server, or a persistent session daemon.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",