@halfwhey/claudraband-core 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/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/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
|
@@ -1096,6 +1096,9 @@ class ClaudeWrapper {
|
|
|
1096
1096
|
if (pane.includes("INSERT") || pane.includes("NORMAL")) {
|
|
1097
1097
|
return;
|
|
1098
1098
|
}
|
|
1099
|
+
if (pane.includes("Yes, I trust this folder") && pane.includes("No, exit") || pane.includes("Bypass Permissions mode") && pane.includes("Yes, I accept")) {
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1099
1102
|
} catch {}
|
|
1100
1103
|
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
1101
1104
|
}
|
|
@@ -1178,6 +1181,9 @@ function parseClaudeArgs(args) {
|
|
|
1178
1181
|
}
|
|
1179
1182
|
// src/claude/inspect.ts
|
|
1180
1183
|
import { readFile } from "node:fs/promises";
|
|
1184
|
+
function normalizeCapturedPane(paneText) {
|
|
1185
|
+
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, "");
|
|
1186
|
+
}
|
|
1181
1187
|
async function hasPendingQuestion(jsonlPath) {
|
|
1182
1188
|
let data;
|
|
1183
1189
|
try {
|
|
@@ -1211,10 +1217,20 @@ async function hasPendingQuestion(jsonlPath) {
|
|
|
1211
1217
|
return pendingIds.size > 0;
|
|
1212
1218
|
}
|
|
1213
1219
|
function parseNativePermissionPrompt(paneText) {
|
|
1214
|
-
const
|
|
1220
|
+
const normalizedPane = normalizeCapturedPane(paneText);
|
|
1221
|
+
const isTrustPrompt = normalizedPane.includes("Yes, I trust this folder") && normalizedPane.includes("No, exit");
|
|
1222
|
+
const isBypassPrompt = normalizedPane.includes("Bypass Permissions mode") && normalizedPane.includes("Yes, I accept");
|
|
1223
|
+
let questionMatch;
|
|
1224
|
+
if (isTrustPrompt) {
|
|
1225
|
+
questionMatch = normalizedPane.match(/(Is this a project you created[^\n]*)/);
|
|
1226
|
+
} else if (isBypassPrompt) {
|
|
1227
|
+
questionMatch = normalizedPane.match(/(you accept all responsibility[^\n]*)/);
|
|
1228
|
+
} else {
|
|
1229
|
+
questionMatch = normalizedPane.match(/(?:^|\n)\s*(Do you want to [^\n]+\?)/);
|
|
1230
|
+
}
|
|
1215
1231
|
if (!questionMatch)
|
|
1216
1232
|
return null;
|
|
1217
|
-
const afterQuestion =
|
|
1233
|
+
const afterQuestion = normalizedPane.slice(normalizedPane.indexOf(questionMatch[1]) + questionMatch[1].length);
|
|
1218
1234
|
const optionRegex = /(?:❯\s*)?(\d+)\.\s+(.+)/g;
|
|
1219
1235
|
const options = [];
|
|
1220
1236
|
let match;
|
|
@@ -1389,6 +1405,10 @@ var TERMINAL_BACKENDS = [
|
|
|
1389
1405
|
description: "Run Claude Code in a headless xterm-backed PTY"
|
|
1390
1406
|
}
|
|
1391
1407
|
];
|
|
1408
|
+
function isRejectingNativeOption(label) {
|
|
1409
|
+
const normalized = label.trim().toLowerCase();
|
|
1410
|
+
return normalized.startsWith("no") || normalized.startsWith("reject");
|
|
1411
|
+
}
|
|
1392
1412
|
function makeDefaultLogger() {
|
|
1393
1413
|
const noop = () => {};
|
|
1394
1414
|
return {
|
|
@@ -1510,6 +1530,8 @@ class ClaudrabandSessionImpl {
|
|
|
1510
1530
|
_permissionMode;
|
|
1511
1531
|
allowTextResponses;
|
|
1512
1532
|
sessionOwner;
|
|
1533
|
+
lastNativePermissionFingerprint = null;
|
|
1534
|
+
lastNativePermissionOutcome = "none";
|
|
1513
1535
|
constructor(wrapper, sessionId, cwd, backend, model, permissionMode, allowTextResponses, logger, onPermissionRequest, lifetime, sessionOwner) {
|
|
1514
1536
|
this.sessionId = sessionId;
|
|
1515
1537
|
this.cwd = cwd;
|
|
@@ -1544,7 +1566,20 @@ class ClaudrabandSessionImpl {
|
|
|
1544
1566
|
const prompt = this.newPromptWaiter(text);
|
|
1545
1567
|
this.activePrompt = prompt;
|
|
1546
1568
|
this.logger.info("prompt received", "sid", this.sessionId, "length", text.length);
|
|
1547
|
-
await this.
|
|
1569
|
+
const startupState = await this.prepareForInput(text);
|
|
1570
|
+
if (startupState === "blocked") {
|
|
1571
|
+
this.activePrompt = null;
|
|
1572
|
+
this.promptAbortController = null;
|
|
1573
|
+
return { stopReason: "end_turn" };
|
|
1574
|
+
}
|
|
1575
|
+
if (startupState === "cancelled") {
|
|
1576
|
+
this.activePrompt = null;
|
|
1577
|
+
this.promptAbortController = null;
|
|
1578
|
+
return { stopReason: "cancelled" };
|
|
1579
|
+
}
|
|
1580
|
+
if (startupState !== "consumed") {
|
|
1581
|
+
await this.wrapper.send(text);
|
|
1582
|
+
}
|
|
1548
1583
|
try {
|
|
1549
1584
|
const stopReason = await this.waitForPromptCompletion(prompt, controller.signal);
|
|
1550
1585
|
this.logger.info("prompt completed", "sid", this.sessionId, "stop_reason", stopReason);
|
|
@@ -1567,6 +1602,17 @@ class ClaudrabandSessionImpl {
|
|
|
1567
1602
|
const prompt = this.newPromptWaiter("");
|
|
1568
1603
|
prompt.matchedUserEcho = true;
|
|
1569
1604
|
this.activePrompt = prompt;
|
|
1605
|
+
const startupState = await this.prepareForInput(null);
|
|
1606
|
+
if (startupState === "blocked") {
|
|
1607
|
+
this.activePrompt = null;
|
|
1608
|
+
this.promptAbortController = null;
|
|
1609
|
+
return { stopReason: "end_turn" };
|
|
1610
|
+
}
|
|
1611
|
+
if (startupState === "cancelled") {
|
|
1612
|
+
this.activePrompt = null;
|
|
1613
|
+
this.promptAbortController = null;
|
|
1614
|
+
return { stopReason: "cancelled" };
|
|
1615
|
+
}
|
|
1570
1616
|
this.logger.info("awaitTurn", "sid", this.sessionId);
|
|
1571
1617
|
try {
|
|
1572
1618
|
const stopReason = await this.waitForPromptCompletion(prompt, controller.signal);
|
|
@@ -1590,6 +1636,26 @@ class ClaudrabandSessionImpl {
|
|
|
1590
1636
|
const prompt = this.newPromptWaiter("");
|
|
1591
1637
|
prompt.matchedUserEcho = true;
|
|
1592
1638
|
this.activePrompt = prompt;
|
|
1639
|
+
const startupState = await this.prepareForInput(text);
|
|
1640
|
+
if (startupState === "blocked") {
|
|
1641
|
+
this.activePrompt = null;
|
|
1642
|
+
this.promptAbortController = null;
|
|
1643
|
+
return { stopReason: "end_turn" };
|
|
1644
|
+
}
|
|
1645
|
+
if (startupState === "cancelled") {
|
|
1646
|
+
this.activePrompt = null;
|
|
1647
|
+
this.promptAbortController = null;
|
|
1648
|
+
return { stopReason: "cancelled" };
|
|
1649
|
+
}
|
|
1650
|
+
if (startupState === "consumed") {
|
|
1651
|
+
if (this.activePrompt === prompt) {
|
|
1652
|
+
this.activePrompt = null;
|
|
1653
|
+
}
|
|
1654
|
+
if (this.promptAbortController === controller) {
|
|
1655
|
+
this.promptAbortController = null;
|
|
1656
|
+
}
|
|
1657
|
+
return { stopReason: "end_turn" };
|
|
1658
|
+
}
|
|
1593
1659
|
this.logger.info("sendAndAwaitTurn", "sid", this.sessionId, "length", text.length);
|
|
1594
1660
|
await this.wrapper.send(text);
|
|
1595
1661
|
try {
|
|
@@ -1608,6 +1674,21 @@ class ClaudrabandSessionImpl {
|
|
|
1608
1674
|
send(text) {
|
|
1609
1675
|
return this.wrapper.send(text);
|
|
1610
1676
|
}
|
|
1677
|
+
async prepareForInput(intendedText) {
|
|
1678
|
+
const handled = await this.pollNativePermission(null, intendedText);
|
|
1679
|
+
if (handled === "handled" || handled === "consumed" || handled === "pending_clear") {
|
|
1680
|
+
const ready = await this.waitForInsertMode();
|
|
1681
|
+
if (!ready) {
|
|
1682
|
+
return this.wrapper.isProcessAlive() ? "blocked" : "cancelled";
|
|
1683
|
+
}
|
|
1684
|
+
return handled === "consumed" ? "consumed" : "ready";
|
|
1685
|
+
}
|
|
1686
|
+
const stillBlocking = hasPendingNativePrompt(await this.wrapper.capturePane().catch(() => ""));
|
|
1687
|
+
if (stillBlocking) {
|
|
1688
|
+
return "blocked";
|
|
1689
|
+
}
|
|
1690
|
+
return "ready";
|
|
1691
|
+
}
|
|
1611
1692
|
async interrupt() {
|
|
1612
1693
|
this.promptAbortController?.abort();
|
|
1613
1694
|
await this.wrapper.interrupt();
|
|
@@ -1927,7 +2008,7 @@ class ClaudrabandSessionImpl {
|
|
|
1927
2008
|
}
|
|
1928
2009
|
if (prompt.pendingTools > 0) {
|
|
1929
2010
|
const handled = await this.pollNativePermission(prompt.lastPendingTool);
|
|
1930
|
-
if (handled) {
|
|
2011
|
+
if (handled === "handled" || handled === "consumed") {
|
|
1931
2012
|
prompt.pendingTools = Math.max(0, prompt.pendingTools - 1);
|
|
1932
2013
|
prompt.gotResponse = true;
|
|
1933
2014
|
prompt.lastPendingTool = null;
|
|
@@ -1942,16 +2023,30 @@ class ClaudrabandSessionImpl {
|
|
|
1942
2023
|
}
|
|
1943
2024
|
}
|
|
1944
2025
|
}
|
|
1945
|
-
async pollNativePermission(pendingTool) {
|
|
2026
|
+
async pollNativePermission(pendingTool, intendedText = null) {
|
|
1946
2027
|
let paneText;
|
|
1947
2028
|
try {
|
|
1948
2029
|
paneText = await this.wrapper.capturePane();
|
|
1949
2030
|
} catch {
|
|
1950
|
-
return
|
|
2031
|
+
return "none";
|
|
1951
2032
|
}
|
|
1952
2033
|
const prompt = parseNativePermissionPrompt(paneText);
|
|
1953
|
-
if (!prompt)
|
|
1954
|
-
|
|
2034
|
+
if (!prompt) {
|
|
2035
|
+
this.lastNativePermissionFingerprint = null;
|
|
2036
|
+
this.lastNativePermissionOutcome = "none";
|
|
2037
|
+
return "none";
|
|
2038
|
+
}
|
|
2039
|
+
const fingerprint = `${prompt.question}
|
|
2040
|
+
${prompt.options.map((option) => `${option.number}:${option.label}`).join(`
|
|
2041
|
+
`)}`;
|
|
2042
|
+
if (this.lastNativePermissionFingerprint === fingerprint) {
|
|
2043
|
+
if (this.lastNativePermissionOutcome === "handled" || this.lastNativePermissionOutcome === "consumed") {
|
|
2044
|
+
return "pending_clear";
|
|
2045
|
+
}
|
|
2046
|
+
if (this.lastNativePermissionOutcome !== "deferred") {
|
|
2047
|
+
return this.lastNativePermissionOutcome;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
1955
2050
|
const decision = await this.resolvePermission({
|
|
1956
2051
|
source: "native_prompt",
|
|
1957
2052
|
sessionId: this.sessionId,
|
|
@@ -1971,19 +2066,62 @@ class ClaudrabandSessionImpl {
|
|
|
1971
2066
|
name: opt.label
|
|
1972
2067
|
}))
|
|
1973
2068
|
});
|
|
2069
|
+
let outcome;
|
|
1974
2070
|
if (decision.outcome === "deferred") {
|
|
1975
|
-
|
|
2071
|
+
outcome = "deferred";
|
|
2072
|
+
} else if (decision.outcome === "cancelled") {
|
|
2073
|
+
await this.safeInterruptNativePermission();
|
|
2074
|
+
outcome = "handled";
|
|
2075
|
+
} else if (decision.outcome === "text") {
|
|
2076
|
+
await this.safeSendNativePermission(decision.text, false);
|
|
2077
|
+
outcome = decision.text === intendedText ? "consumed" : "handled";
|
|
2078
|
+
} else {
|
|
2079
|
+
const selected = prompt.options.find((option) => option.number === decision.optionId);
|
|
2080
|
+
const tolerateExit = selected ? isRejectingNativeOption(selected.label) : false;
|
|
2081
|
+
await this.safeSendNativePermission(decision.optionId, tolerateExit);
|
|
2082
|
+
outcome = decision.optionId === intendedText ? "consumed" : "handled";
|
|
2083
|
+
}
|
|
2084
|
+
this.lastNativePermissionFingerprint = fingerprint;
|
|
2085
|
+
this.lastNativePermissionOutcome = outcome;
|
|
2086
|
+
return outcome;
|
|
2087
|
+
}
|
|
2088
|
+
async safeSendNativePermission(input, tolerateExit) {
|
|
2089
|
+
try {
|
|
2090
|
+
await this.wrapper.send(input);
|
|
2091
|
+
} catch (error) {
|
|
2092
|
+
if (tolerateExit && !this.wrapper.isProcessAlive()) {
|
|
2093
|
+
this.logger.debug("native permission send ignored after pane exit", "sid", this.sessionId);
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
throw error;
|
|
1976
2097
|
}
|
|
1977
|
-
|
|
2098
|
+
}
|
|
2099
|
+
async safeInterruptNativePermission() {
|
|
2100
|
+
try {
|
|
1978
2101
|
await this.wrapper.interrupt();
|
|
1979
|
-
|
|
2102
|
+
} catch (error) {
|
|
2103
|
+
if (!this.wrapper.isProcessAlive()) {
|
|
2104
|
+
this.logger.debug("native permission interrupt ignored after pane exit", "sid", this.sessionId);
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
throw error;
|
|
1980
2108
|
}
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
2109
|
+
}
|
|
2110
|
+
async waitForInsertMode(timeoutMs = 15000) {
|
|
2111
|
+
const POLL_MS = 300;
|
|
2112
|
+
const start = Date.now();
|
|
2113
|
+
while (Date.now() - start < timeoutMs) {
|
|
2114
|
+
if (!this.wrapper.isProcessAlive()) {
|
|
2115
|
+
return false;
|
|
2116
|
+
}
|
|
2117
|
+
try {
|
|
2118
|
+
const pane = await this.wrapper.capturePane();
|
|
2119
|
+
if (pane.includes("INSERT") || pane.includes("NORMAL"))
|
|
2120
|
+
return true;
|
|
2121
|
+
} catch {}
|
|
2122
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
1984
2123
|
}
|
|
1985
|
-
|
|
1986
|
-
return true;
|
|
2124
|
+
return false;
|
|
1987
2125
|
}
|
|
1988
2126
|
async handleUserQuestion(ev) {
|
|
1989
2127
|
const parsed = parseAskUserQuestion(ev.toolInput);
|
package/package.json
CHANGED