@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 +75 -63
- package/dist/bin.js +185 -29
- package/dist/claude/claude.d.ts +3 -0
- package/dist/index.js +154 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,149 +1,161 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# Claudraband
|
|
4
4
|
|
|
5
5
|
Claude Code for the power user
|
|
6
6
|
|
|
7
|
-
> Experimental: this project is still evolving
|
|
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
|
-
[
|
|
13
|
-
[
|
|
11
|
+
[Daemon API](docs/daemon-api.md) •
|
|
12
|
+
[Examples](examples/)
|
|
14
13
|
|
|
15
14
|
</div>
|
|
16
15
|
|
|
17
|
-
`claudraband` wraps
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
Requirements:
|
|
30
34
|
|
|
31
35
|
- Node.js or Bun
|
|
32
36
|
- An already authenticated Claude Code
|
|
33
|
-
- `tmux`
|
|
37
|
+
- `tmux` for the first-class local and daemon-backed workflow
|
|
34
38
|
|
|
35
|
-
|
|
39
|
+
Install or run:
|
|
36
40
|
|
|
37
41
|
```sh
|
|
38
|
-
#
|
|
42
|
+
# one-off
|
|
39
43
|
npx @halfwhey/claudraband "review the staged diff"
|
|
44
|
+
bunx @halfwhey/claudraband "review the staged diff"
|
|
40
45
|
|
|
41
|
-
#
|
|
46
|
+
# install once
|
|
42
47
|
npm install -g @halfwhey/claudraband
|
|
43
48
|
```
|
|
44
49
|
|
|
45
|
-
|
|
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
|
-
|
|
54
|
+
The two first-class paths are local `tmux` sessions and daemon-backed sessions.
|
|
55
55
|
|
|
56
|
-
|
|
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
|
-
###
|
|
65
|
+
### Daemon-backed sessions
|
|
76
66
|
|
|
77
67
|
```sh
|
|
78
|
-
cband serve --host 127.0.0.1 --
|
|
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
|
|
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
|
-
|
|
91
|
-
- `--permission-mode bypassPermissions`
|
|
76
|
+
## Experimental Xterm Backend
|
|
92
77
|
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
87
|
+
# example: toad
|
|
103
88
|
uvx --from batrachian-toad toad acp 'cband acp -c "--model haiku"'
|
|
104
89
|
```
|
|
105
90
|
|
|
106
|
-
|
|
91
|
+
Editor and ACP client support varies by frontend, but `claudraband` itself supports session follow and resume through ACP.
|
|
107
92
|
|
|
108
|
-
## Session
|
|
93
|
+
## Session Model
|
|
109
94
|
|
|
110
95
|
Live sessions are tracked in `~/.claudraband/`.
|
|
111
96
|
|
|
112
|
-
- `cband sessions`
|
|
113
|
-
- `continue` can resume an existing Claude Code session even when it is no longer live
|
|
114
|
-
- `
|
|
115
|
-
- `sessions close
|
|
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
|
-
|
|
106
|
+
Claude can interrogate an older Claude session and justify the choices it made.
|
|
122
107
|
|
|
123
108
|

|
|
124
109
|
|
|
125
110
|
### Toad via ACP
|
|
126
111
|
|
|
127
|
-
Toad can use `claudraband acp`
|
|
112
|
+
Toad can use `claudraband acp` as an alternative frontend for Claude Code.
|
|
128
113
|
|
|
129
114
|

|
|
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
|

|
|
134
119
|
|
|
135
120
|
### Zed via ACP
|
|
136
121
|
|
|
137
|
-
Zed can also use `claudraband acp`
|
|
122
|
+
Zed can also use `claudraband acp` as an alternative frontend.
|
|
138
123
|
|
|
139
124
|

|
|
140
125
|
|
|
141
|
-
## Library
|
|
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)
|
|
146
|
-
- [`examples/multi-session.ts`](examples/multi-session.ts)
|
|
147
|
-
- [`examples/session-journal.ts`](examples/session-journal.ts)
|
|
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
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
|
6341
|
+
return "none";
|
|
6261
6342
|
}
|
|
6262
6343
|
const prompt = parseNativePermissionPrompt(paneText);
|
|
6263
|
-
if (!prompt)
|
|
6264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6292
|
-
|
|
6293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 : "
|
|
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
|
|
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
|
-
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
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();
|
package/dist/claude/claude.d.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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.
|
|
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
|
|
6390
|
+
return "none";
|
|
6310
6391
|
}
|
|
6311
6392
|
const prompt = parseNativePermissionPrompt(paneText);
|
|
6312
|
-
if (!prompt)
|
|
6313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6457
|
+
}
|
|
6458
|
+
async safeInterruptNativePermission() {
|
|
6459
|
+
try {
|
|
6337
6460
|
await this.wrapper.interrupt();
|
|
6338
|
-
|
|
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
|
-
|
|
6341
|
-
|
|
6342
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|