@qilin-agent/tool-bash-persistent 0.0.0-stage → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +79 -0
- package/README.md +157 -2
- package/README.zh.md +158 -0
- package/lib/index.js +389 -0
- package/lib/types/index.d.ts +24 -0
- package/package.json +56 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kkutysllb
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
|
|
2
|
+
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
|
3
|
+
# After editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/shell/tool-bash-persistent/README.md
|
|
5
|
+
/:
|
|
6
|
+
en: 1516a333e77ca54a
|
|
7
|
+
zh: 53ef80da9cefd884
|
|
8
|
+
/qilin-tool-bash-persistent:
|
|
9
|
+
en: 0b2f94e7a94335b6
|
|
10
|
+
zh: 0b95644899dbcdd7
|
|
11
|
+
/qilin-tool-bash-persistent/summary:
|
|
12
|
+
en: c34db740c58468e2
|
|
13
|
+
zh: 712a34519dd66922
|
|
14
|
+
/qilin-tool-bash-persistent/table-of-contents:
|
|
15
|
+
en: d152484eb41ac6b4
|
|
16
|
+
zh: 09388d293f9be9cb
|
|
17
|
+
/qilin-tool-bash-persistent/use-this-package:
|
|
18
|
+
en: ddec7cc1004498f8
|
|
19
|
+
zh: cd0cba22e570d1e9
|
|
20
|
+
/qilin-tool-bash-persistent/use-this-package/when-to-choose-it:
|
|
21
|
+
en: c44e27f03b5bf48c
|
|
22
|
+
zh: a0bb453d130cf6ae
|
|
23
|
+
/qilin-tool-bash-persistent/use-this-package/minimal-configuration:
|
|
24
|
+
en: 65386955f4a6a3a8
|
|
25
|
+
zh: 9fccd80dc540ab58
|
|
26
|
+
/qilin-tool-bash-persistent/use-this-package/what-the-agent-can-rely-on:
|
|
27
|
+
en: b398f124ea7b77b0
|
|
28
|
+
zh: 50855e601e6c1b88
|
|
29
|
+
/qilin-tool-bash-persistent/use-this-package/what-can-go-wrong:
|
|
30
|
+
en: d0d44ffa08f1a917
|
|
31
|
+
zh: ece228da0c0089aa
|
|
32
|
+
/qilin-tool-bash-persistent/understand-the-implementation:
|
|
33
|
+
en: 8d94a7381bca3fa9
|
|
34
|
+
zh: f4bd2866fff6fa8d
|
|
35
|
+
/qilin-tool-bash-persistent/understand-the-implementation/design-philosophy:
|
|
36
|
+
en: 61c39f52982612e3
|
|
37
|
+
zh: 92bc7e3c8d13a211
|
|
38
|
+
/qilin-tool-bash-persistent/understand-the-implementation/source-map:
|
|
39
|
+
en: 857e036f41c913ac
|
|
40
|
+
zh: e9f383f7ee384c59
|
|
41
|
+
/qilin-tool-bash-persistent/understand-the-implementation/command-flow:
|
|
42
|
+
en: 3b7bf7c20b266c31
|
|
43
|
+
zh: 85a325ef65fa7ac7
|
|
44
|
+
/qilin-tool-bash-persistent/further-exploration:
|
|
45
|
+
en: 2f6547ad7a0e569c
|
|
46
|
+
zh: 2f9b5f19b3ebcc18
|
|
47
|
+
/qilin-tool-bash-persistent/model-experience:
|
|
48
|
+
en: 215e7ba838619b7b
|
|
49
|
+
zh: a311da3843709f90
|
|
50
|
+
/qilin-tool-bash-persistent/model-experience/tool-schema:
|
|
51
|
+
en: e4c7376d4f29dbd5
|
|
52
|
+
zh: 6b7a9eaf69c9ea53
|
|
53
|
+
/qilin-tool-bash-persistent/model-experience/tool-schema/what-the-model-sees:
|
|
54
|
+
en: 17853846e6afc910
|
|
55
|
+
zh: 52805c18361c2650
|
|
56
|
+
/qilin-tool-bash-persistent/model-experience/tool-schema/token-effect:
|
|
57
|
+
en: feee7dec91ae7329
|
|
58
|
+
zh: b62338fa72ad3b87
|
|
59
|
+
/qilin-tool-bash-persistent/model-experience/tool-schema/kv-cache-effect:
|
|
60
|
+
en: 7d9323583a83cb95
|
|
61
|
+
zh: cc5daa6bce8450f8
|
|
62
|
+
/qilin-tool-bash-persistent/model-experience/tool-results:
|
|
63
|
+
en: beaabb79f229692a
|
|
64
|
+
zh: 8573891441963be4
|
|
65
|
+
/qilin-tool-bash-persistent/model-experience/tool-results/what-the-model-sees:
|
|
66
|
+
en: 7c197e9bb86cc044
|
|
67
|
+
zh: d8ffe918aab08250
|
|
68
|
+
/qilin-tool-bash-persistent/model-experience/tool-results/token-effect:
|
|
69
|
+
en: 5dea04ce6a30ad48
|
|
70
|
+
zh: c2434253dc517d3d
|
|
71
|
+
/qilin-tool-bash-persistent/model-experience/tool-results/kv-cache-effect:
|
|
72
|
+
en: ba6a48888c3361e5
|
|
73
|
+
zh: 3a8f648053555dc9
|
|
74
|
+
/qilin-tool-bash-persistent/known-limitations-and-deferred-work:
|
|
75
|
+
en: dc5a908f2b2da040
|
|
76
|
+
zh: 3afceaabcf324771
|
|
77
|
+
/qilin-tool-bash-persistent/known-limitations-and-deferred-work/dev-note:
|
|
78
|
+
en: e67f633dc6fe7773
|
|
79
|
+
zh: 3bb0c20c93f16f98
|
package/README.md
CHANGED
|
@@ -1,3 +1,158 @@
|
|
|
1
|
-
|
|
1
|
+
---
|
|
2
|
+
description: "The model-facing persistent bash tool for users and maintainers choosing, configuring, or debugging owner-scoped shell state that survives across calls."
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
# @qilin-agent/tool-bash-persistent
|
|
7
|
+
|
|
8
|
+
English | [中文](README.zh.md)
|
|
9
|
+
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
This package gives an agent a `bash` tool whose cwd, exported variables, functions, and background jobs persist across calls. Each agent receives an isolated shell, and its commands run sequentially. Choose it for workflows that depend on cross-call state; use `qilin-tool-bash` when every command should start clean. Configure the PTY backend and per-command timeout; `exit`, timeout, or cancellation resets the shell, while interactive commands that wait for stdin may run until timeout.
|
|
13
|
+
|
|
14
|
+
## Table of Contents
|
|
15
|
+
|
|
16
|
+
- [Use this package](#use-this-package)
|
|
17
|
+
- [Understand the implementation](#understand-the-implementation)
|
|
18
|
+
- [Further Exploration](#further-exploration)
|
|
19
|
+
- [Model Experience](#model-experience)
|
|
20
|
+
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
|
21
|
+
- [Dev Note](#dev-note)
|
|
22
|
+
|
|
23
|
+
-----
|
|
24
|
+
|
|
25
|
+
<a id="use-this-package"></a>
|
|
26
|
+
## Use this package
|
|
27
|
+
|
|
28
|
+
Load this plugin in any composition where the agent should keep shell state between commands — for example long build sessions, activated environments, or scripts that export variables for later steps. It registers the `bash` tool and requires the `ctx.tools` and `ctx.terminals` services plus an owning agent session at execution time.
|
|
29
|
+
|
|
30
|
+
### When to choose it
|
|
31
|
+
|
|
32
|
+
Choose the persistent tool when work depends on cross-call state: a one-shot `qilin-tool-bash` call cannot remember a `cd` or an exported variable. Choose the one-shot tool when every command should start from a known, clean environment, or when the command is short and self-contained. Commands that need interactive stdin are unsupported here — a foreground child that reads input blocks until the command timeout — so interactive work belongs to the terminal tools.
|
|
33
|
+
|
|
34
|
+
### Minimal configuration
|
|
35
|
+
|
|
36
|
+
The default `shell` backend starts an interactive bash through `qilin-terminal-bash`; deployments may register another PTY backend and select it by name.
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
- name: '@qilin-agent/terminal'
|
|
40
|
+
- name: '@qilin-agent/terminal-bash'
|
|
41
|
+
- name: '@qilin-agent/tool-bash-persistent'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
| Field | Default | Meaning |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `backendType` | `shell` | Registered PTY backend used for each agent's shell |
|
|
47
|
+
| `timeoutMs` | `300,000` | Wall-clock limit for one command; timeout closes the shell |
|
|
48
|
+
| `maxOutputChars` | `16,000` | Maximum retained command-output characters; fixed diagnostics are added afterward |
|
|
49
|
+
| `description` | `Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.` | Model-facing environment contract; deployments may describe their environment |
|
|
50
|
+
|
|
51
|
+
The generated [configuration catalog](../../../docs/config-catalog.md#qilin-agenttool-bash-persistent) is the exhaustive source for every accepted field and its JSDoc.
|
|
52
|
+
|
|
53
|
+
### What the agent can rely on
|
|
54
|
+
|
|
55
|
+
Commands share one shell per agent, so state persists until an `exit`, a timeout, or a reset — each of which closes the shell and tells the agent the next call starts from the workspace with a fresh directory and environment. Results exclude the private completion markers; every settled command appends `[Command finished with exit code N]`, and a shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]`, then resets. Long output keeps the earliest retained prefix plus a clipping notice; if the terminal has already dropped that prefix, the result says so explicitly rather than presenting a tail as complete output.
|
|
56
|
+
|
|
57
|
+
### What can go wrong
|
|
58
|
+
|
|
59
|
+
A call without an owning agent session fails with `bash requires an owning agent session`, and a composition without a PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. An interactive foreground child (for example a REPL) returns early with partial output only where the backend proves its stdin wait; elsewhere the call runs to `timeoutMs`, which closes the uncertain shell and reports the reset. Cancellation also resets and discards the result, even when a complete status marker is already observable.
|
|
60
|
+
|
|
61
|
+
-----
|
|
62
|
+
|
|
63
|
+
<a id="understand-the-implementation"></a>
|
|
64
|
+
## Understand the implementation
|
|
65
|
+
|
|
66
|
+
<details>
|
|
67
|
+
<summary>Implementation internals — click to expand</summary>
|
|
68
|
+
|
|
69
|
+
This section explains the design decisions behind the tool and points at the code that realizes them; the observable behavior is fully covered in [Use this package](#use-this-package).
|
|
70
|
+
|
|
71
|
+
### Design philosophy
|
|
72
|
+
|
|
73
|
+
- **One shell per owner, nothing shared.** The shell registry keys every session by the calling `Agent`, so concurrent agents never share state, and commands for the same agent are serialized through a per-owner queue.
|
|
74
|
+
- **Marker-anchored extraction.** Each command is wrapped with unique start/end markers carrying the exit status; the tool polls the PTY scrollback and extracts the span between the real markers, so prompts and echoed input never leak into results.
|
|
75
|
+
- **Reset, never repair.** Any uncertain state — an explicit `exit`, a timeout, a send failure, an abort — closes the shell and starts the next call fresh, because a half-known shell is worse than a clean one.
|
|
76
|
+
- **Owner-scoped lifecycle.** Shells are created lazily on first use and killed on plugin disposal or owner teardown; the owner-scoped `ctx.terminals` service fences every operation to the owning agent.
|
|
77
|
+
|
|
78
|
+
### Source map
|
|
79
|
+
|
|
80
|
+
| File | Role |
|
|
81
|
+
|---|---|
|
|
82
|
+
| [`src/index.ts`](src/index.ts) | Plugin entry: shell registry, command wrapping, scrollback polling, extraction and rendering |
|
|
83
|
+
|
|
84
|
+
### Command flow
|
|
85
|
+
|
|
86
|
+
A first command spawns the shell through `ctx.terminals.spawn`, disables input echo (`stty -echo`), and waits for readiness. Each command is then wrapped into one physical line — a printf of the start marker, the command body escaped with `$'…'`, and a printf of the end marker plus `$?` — so embedded newlines cannot leak terminal prompts into the result. The tool polls the scrollback in 1,000-line pages until the end marker appears, extracts the span, and renders it with any status marker. A timeout aborts the deadline, captures the partial output, and resets the shell.
|
|
87
|
+
|
|
88
|
+
</details>
|
|
89
|
+
|
|
90
|
+
-----
|
|
91
|
+
|
|
92
|
+
<a id="further-exploration"></a>
|
|
93
|
+
## Further Exploration
|
|
94
|
+
|
|
95
|
+
Read these pages when the package-level contract is not enough. They move from the terminal family to the seam, the backends, and the design note behind owner-scoped sessions.
|
|
96
|
+
|
|
97
|
+
- [terminal package map](../../terminal/README.md) — the persistent PTY capability family.
|
|
98
|
+
- [terminal seam](../../terminal/terminal/README.md) — the `ctx.terminals` service behind the tool.
|
|
99
|
+
- [terminal-bash backend](../../terminal/terminal-bash/README.md) — the default `shell` backend.
|
|
100
|
+
- [tool-terminal](../../terminal/tool-terminal/README.md) — six model-facing terminal tools for interactive work.
|
|
101
|
+
- [Persistent PTY sessions Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) — the owner-scoped session design and its rationale.
|
|
102
|
+
- [Generated tool catalog](../../../docs/tool-catalog.md#qilin-agenttool-bash-persistent) — the exact `bash` argument schema.
|
|
103
|
+
- [Generated configuration catalog](../../../docs/config-catalog.md#qilin-agenttool-bash-persistent) — every accepted config field and its source declaration.
|
|
104
|
+
|
|
105
|
+
-----
|
|
106
|
+
|
|
107
|
+
<a id="model-experience"></a>
|
|
108
|
+
## Model Experience
|
|
109
|
+
|
|
110
|
+
### Tool schema
|
|
111
|
+
|
|
112
|
+
#### What the model sees
|
|
113
|
+
|
|
114
|
+
The generated [`bash` schema](../../../docs/tool-catalog.md#qilin-agenttool-bash-persistent), including the configured `description`. The plugin contributes no standalone system-prompt section; the deployment owns persona and environment guidance.
|
|
115
|
+
|
|
116
|
+
#### Token effect
|
|
117
|
+
|
|
118
|
+
Fixed schema cost while `bash` is visible.
|
|
119
|
+
|
|
120
|
+
#### KV Cache effect
|
|
121
|
+
|
|
122
|
+
Prefix-stable while the configured description and schema remain unchanged.
|
|
123
|
+
|
|
124
|
+
### Tool results
|
|
125
|
+
|
|
126
|
+
#### What the model sees
|
|
127
|
+
|
|
128
|
+
Commands share one shell per agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers. When the shell reads stdin again without having printed the completion marker — after `exec`, an interrupt, or an interactive foreground child whose stdin wait the provider proves — the call returns the captured partial output, which can end with the backend's own prompt text. Every settled command appends `[Command finished with exit code N]`; a shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither, then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output followed by `[Command timed out or OOM]`, closes the uncertain shell, and reports the reset.
|
|
129
|
+
|
|
130
|
+
#### Token effect
|
|
131
|
+
|
|
132
|
+
Data-dependent. `maxOutputChars` bounds retained command output; fixed clipping, lost-prefix, status, timeout, and reset diagnostics can extend the result.
|
|
133
|
+
|
|
134
|
+
#### KV Cache effect
|
|
135
|
+
|
|
136
|
+
Append-only tool results follow the reusable request prefix.
|
|
137
|
+
|
|
138
|
+
## Known Limitations and Deferred Work
|
|
139
|
+
|
|
140
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
These limits define when the tool is a poor fit or needs special care. They are current package constraints, not a task backlog.
|
|
144
|
+
|
|
145
|
+
- **The tool requires an owning agent and a real PTY backend** — agent-less calls and backends that cannot start an interactive shell fail.
|
|
146
|
+
- **An interactive foreground child returns early with partial output only where the subprocess provider proves its stdin wait** — elsewhere the call runs to `timeoutMs`.
|
|
147
|
+
- **Explicit `exit` and timeout discard shell state** — cancellation also resets and discards the result, even when a complete status marker is already observable; the next call starts a fresh shell.
|
|
148
|
+
- **Environment facts such as network access and package mirrors belong in the configured `description`** — not this package's default.
|
|
149
|
+
|
|
150
|
+
<a id="dev-note"></a>
|
|
151
|
+
### Dev Note
|
|
152
|
+
|
|
153
|
+
<details>
|
|
154
|
+
<summary>Working context for maintainers — click to expand</summary>
|
|
155
|
+
|
|
156
|
+
None.
|
|
157
|
+
|
|
158
|
+
</details>
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "面向模型的持久 bash 工具,供选择、配置或排查跨调用保留的按所有者隔离 shell 状态的使用者与维护者阅读。"
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @qilin-agent/tool-bash-persistent
|
|
7
|
+
|
|
8
|
+
[English](README.md) | 中文
|
|
9
|
+
|
|
10
|
+
## 概述
|
|
11
|
+
|
|
12
|
+
本包为 agent(智能体)提供 `bash` 工具,使 cwd、导出的变量、函数与后台任务跨调用保留。每个 agent 都有隔离的 shell,其命令串行执行。需要跨调用状态的工作流应选择本包;每条命令都应从干净环境开始时使用 `qilin-tool-bash`。配置 PTY 后端与单条命令的超时;`exit`、超时或取消会重置 shell,而等待 stdin 的交互式命令可能一直运行到超时。
|
|
13
|
+
|
|
14
|
+
## 目录
|
|
15
|
+
|
|
16
|
+
- [使用本包](#use-this-package)
|
|
17
|
+
- [理解实现](#understand-the-implementation)
|
|
18
|
+
- [进一步探索](#further-exploration)
|
|
19
|
+
- [模型体验](#model-experience)
|
|
20
|
+
- [已知限制与延期工作](#known-limitations-and-deferred-work)
|
|
21
|
+
- [开发备注](#dev-note)
|
|
22
|
+
|
|
23
|
+
-----
|
|
24
|
+
|
|
25
|
+
<a id="use-this-package"></a>
|
|
26
|
+
## 使用本包
|
|
27
|
+
|
|
28
|
+
在 agent 需要在命令之间保持 shell 状态的任何组合中加载本插件——例如长时间构建会话、已激活的环境,或为后续步骤导出变量的脚本。它注册 `bash` 工具,需要 `ctx.tools` 与 `ctx.terminals` 服务,并在执行时需要拥有者 agent 会话。
|
|
29
|
+
|
|
30
|
+
### 何时选择
|
|
31
|
+
|
|
32
|
+
当工作依赖跨调用状态时选择持久工具:一次性 `qilin-tool-bash` 调用无法记住 `cd` 或导出的变量。当每条命令都应从已知、干净的环境开始,或命令又短又独立时,选择一次性工具。这里不支持需要交互 stdin 的命令——读取输入的前台子进程会一直阻塞到命令超时——因此交互工作属于 terminal 工具。
|
|
33
|
+
|
|
34
|
+
### 最小配置
|
|
35
|
+
|
|
36
|
+
默认的 `shell` 后端通过 `qilin-terminal-bash` 启动交互式 bash;部署方可以注册其他 PTY 后端并按名称选择。
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
- name: '@qilin-agent/terminal'
|
|
40
|
+
- name: '@qilin-agent/terminal-bash'
|
|
41
|
+
- name: '@qilin-agent/tool-bash-persistent'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
| 字段 | 默认值 | 含义 |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `backendType` | `shell` | 用于每个 agent shell 的已注册 PTY 后端 |
|
|
47
|
+
| `timeoutMs` | `300,000` | 单条命令的墙钟上限;超时关闭 shell |
|
|
48
|
+
| `maxOutputChars` | `16,000` | 保留的命令输出字符上限;固定诊断信息在其后追加 |
|
|
49
|
+
| `description` | `Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.` | 面向模型的环境约定;部署方可描述自己的环境 |
|
|
50
|
+
|
|
51
|
+
生成的[配置目录](../../../docs/config-catalog.zh.md#qilin-agenttool-bash-persistent)是每个受支持字段及其 JSDoc 的穷尽式真源。
|
|
52
|
+
|
|
53
|
+
### agent 可以依赖什么
|
|
54
|
+
|
|
55
|
+
命令共享每个 agent 一个 shell,因此状态一直保留到 `exit`、超时或重置——每一种都会关闭 shell 并告诉 agent 下一次调用从工作区的新目录与环境开始。结果排除私有完成标记;每条完成的命令都追加 `[Command finished with exit code N]`,而在报告该状态前就退出的 shell 改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]` 或 `[shell exited]`,然后重置。长输出保留最早的已保留前缀并附裁剪通知;若 terminal 已经丢弃该前缀,结果会明确说明,而不是把尾部当作完整输出呈现。
|
|
56
|
+
|
|
57
|
+
### 可能出什么问题
|
|
58
|
+
|
|
59
|
+
没有拥有者 agent 会话的调用会以 `bash requires an owning agent session` 失败,没有 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。交互式前台子进程(例如 REPL)只有在后端证明其 stdin 等待时才提前返回部分输出;否则调用一直运行到 `timeoutMs`,随后关闭不确定的 shell 并报告重置。取消也会重置并丢弃结果,即使完整状态标记已经可观察。
|
|
60
|
+
|
|
61
|
+
-----
|
|
62
|
+
|
|
63
|
+
<a id="understand-the-implementation"></a>
|
|
64
|
+
## 理解实现
|
|
65
|
+
|
|
66
|
+
<details>
|
|
67
|
+
<summary>实现细节——点击展开</summary>
|
|
68
|
+
|
|
69
|
+
本节解释工具背后的设计决策,并指出实现它们的代码位置;可观察行为已在[使用本包](#use-this-package)中完整说明。
|
|
70
|
+
|
|
71
|
+
### 设计理念
|
|
72
|
+
|
|
73
|
+
- **每个 owner 一个 shell,互不共享。** shell 注册表按调用方 `Agent` 为每个会话建键,因此并发 agent 永不共享状态,同一 agent 的命令通过按 owner 的队列串行化。
|
|
74
|
+
- **标记锚定提取。** 每条命令都用携带退出状态的唯一起止标记包装;工具轮询 PTY scrollback 并提取真实标记之间的区间,因此提示符与回显输入永不泄漏进结果。
|
|
75
|
+
- **重置,而非修复。** 任何不确定状态——显式 `exit`、超时、发送失败、中止——都会关闭 shell 并让下一次调用从全新状态开始,因为半知情的 shell 不如干净的 shell。
|
|
76
|
+
- **按 owner 的生命周期。** shell 在首次使用时惰性创建,在插件释放或 owner 拆除时终止;按所有者隔离的 `ctx.terminals` 服务把每个操作都围栏到拥有它的 agent。
|
|
77
|
+
|
|
78
|
+
### 源码地图
|
|
79
|
+
|
|
80
|
+
| 文件 | 职责 |
|
|
81
|
+
|---|---|
|
|
82
|
+
| [`src/index.ts`](src/index.ts) | 插件入口:shell 注册表、命令包装、scrollback 轮询、提取与渲染 |
|
|
83
|
+
|
|
84
|
+
### 命令流程
|
|
85
|
+
|
|
86
|
+
首条命令通过 `ctx.terminals.spawn` 生成 shell,禁用输入回显(`stty -echo`),并等待就绪。随后每条命令都包装成一行物理文本——printf 起始标记、用 `$'…'` 转义的命令体、printf 结束标记加 `$?`——因此内嵌换行无法把终端提示符泄漏进结果。工具以 1,000 行一页轮询 scrollback,直到出现结束标记,提取区间并连同任何状态标记一起渲染。超时会中止截止时间、捕获部分输出并重置 shell。
|
|
87
|
+
|
|
88
|
+
</details>
|
|
89
|
+
|
|
90
|
+
-----
|
|
91
|
+
|
|
92
|
+
<a id="further-exploration"></a>
|
|
93
|
+
## 进一步探索
|
|
94
|
+
|
|
95
|
+
当包级约定不够用时阅读以下页面。它们从 terminal 家族逐步进入 seam、后端,以及按所有者会话背后的设计笔记。
|
|
96
|
+
|
|
97
|
+
- [terminal 包映射](../../terminal/README.zh.md)——持久 PTY 能力家族。
|
|
98
|
+
- [terminal seam](../../terminal/terminal/README.zh.md)——工具背后的 `ctx.terminals` 服务。
|
|
99
|
+
- [terminal-bash 后端](../../terminal/terminal-bash/README.zh.md)——默认的 `shell` 后端。
|
|
100
|
+
- [tool-terminal](../../terminal/tool-terminal/README.zh.md)——面向交互工作的六个模型侧 terminal 工具。
|
|
101
|
+
- [持久 PTY 会话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md)——按所有者会话的设计及其理由。
|
|
102
|
+
- [生成的工具目录](../../../docs/tool-catalog.zh.md#qilin-agenttool-bash-persistent)——`bash` 参数 schema 的确切内容。
|
|
103
|
+
- [生成的配置目录](../../../docs/config-catalog.zh.md#qilin-agenttool-bash-persistent)——每个受支持配置字段及其源声明。
|
|
104
|
+
|
|
105
|
+
-----
|
|
106
|
+
|
|
107
|
+
<a id="model-experience"></a>
|
|
108
|
+
## 模型体验
|
|
109
|
+
|
|
110
|
+
### 工具 schema
|
|
111
|
+
|
|
112
|
+
#### 模型看到什么
|
|
113
|
+
|
|
114
|
+
生成的 [`bash` schema](../../../docs/tool-catalog.zh.md#qilin-agenttool-bash-persistent),包括配置的 `description`。本插件不贡献独立的系统提示词区段;人设与环境指引由部署方负责。
|
|
115
|
+
|
|
116
|
+
#### Token 影响
|
|
117
|
+
|
|
118
|
+
`bash` 可见期间产生固定 schema 开销。
|
|
119
|
+
|
|
120
|
+
#### KV Cache 影响
|
|
121
|
+
|
|
122
|
+
只要配置的描述与 schema 不变,前缀就保持稳定。
|
|
123
|
+
|
|
124
|
+
### 工具结果
|
|
125
|
+
|
|
126
|
+
#### 模型看到什么
|
|
127
|
+
|
|
128
|
+
命令共享每个 agent 一个 shell,因此 cwd、导出的变量、已激活的环境、函数与后台任务都会跨调用保留。结果排除私有完成标记。当 shell 在没有打印完成标记的情况下再次读取 stdin——`exec`、中断,或提供方证明其 stdin 等待的交互式前台子进程之后——调用返回捕获的部分输出,它可能以后端自己的提示符文本结尾。每条完成的命令都追加 `[Command finished with exit code N]`;在报告该状态前就退出的 shell 改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]`,或后端两者都未提供时的 `[shell exited]`,然后重置并告诉模型下一次调用从全新状态开始。长输出保留最早的已保留前缀并附裁剪通知。若 PTY 已经丢弃该前缀,结果会明确说明,而不是把尾部当作完整输出呈现。超时返回有界部分输出并追加 `[Command timed out or OOM]`、关闭不确定的 shell 并报告重置。
|
|
129
|
+
|
|
130
|
+
#### Token 影响
|
|
131
|
+
|
|
132
|
+
依数据而定。`maxOutputChars` 限制保留的命令输出;固定的裁剪、丢失前缀、状态、超时与重置诊断可能延长结果。
|
|
133
|
+
|
|
134
|
+
#### KV Cache 影响
|
|
135
|
+
|
|
136
|
+
仅追加的工具结果位于可复用请求前缀之后。
|
|
137
|
+
|
|
138
|
+
## 已知限制与延期工作
|
|
139
|
+
|
|
140
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
这些限制说明工具何时不合适或需要特别小心。它们是当前包约束,不是任务积压。
|
|
144
|
+
|
|
145
|
+
- **工具需要拥有者 agent 与真实的 PTY 后端**——无 agent 的调用与无法启动交互 shell 的后端都会失败。
|
|
146
|
+
- **交互式前台子进程只在子进程提供方证明其 stdin 等待时才提前返回部分输出**——否则调用一直运行到 `timeoutMs`。
|
|
147
|
+
- **显式 `exit` 与超时会丢弃 shell 状态**——取消同样重置并丢弃结果,即使完整状态标记已经可观察;下一次调用启动全新 shell。
|
|
148
|
+
- **网络访问与包镜像等环境事实属于配置的 `description`**——而不是本包的默认描述。
|
|
149
|
+
|
|
150
|
+
<a id="dev-note"></a>
|
|
151
|
+
### 开发备注
|
|
152
|
+
|
|
153
|
+
<details>
|
|
154
|
+
<summary>维护者的工作上下文——点击展开</summary>
|
|
155
|
+
|
|
156
|
+
无。
|
|
157
|
+
|
|
158
|
+
</details>
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import z from "@qilin-agent/schemastery";
|
|
3
|
+
import { deadline, timeoutOf } from "@qilin-agent/timeout";
|
|
4
|
+
import { defineTool } from "@qilin-agent/tools";
|
|
5
|
+
//#region lib/types/index.js
|
|
6
|
+
/**
|
|
7
|
+
* Model-facing persistent `bash` tool over the owner-scoped PTY seam.
|
|
8
|
+
* @module @qilin-agent/tool-bash-persistent
|
|
9
|
+
*/
|
|
10
|
+
var __addDisposableResource = function(env, value, async) {
|
|
11
|
+
if (value !== null && value !== void 0) {
|
|
12
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
13
|
+
var dispose, inner;
|
|
14
|
+
if (async) {
|
|
15
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
16
|
+
dispose = value[Symbol.asyncDispose];
|
|
17
|
+
}
|
|
18
|
+
if (dispose === void 0) {
|
|
19
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
20
|
+
dispose = value[Symbol.dispose];
|
|
21
|
+
if (async) inner = dispose;
|
|
22
|
+
}
|
|
23
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
24
|
+
if (inner) dispose = function() {
|
|
25
|
+
try {
|
|
26
|
+
inner.call(this);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
return Promise.reject(e);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
env.stack.push({
|
|
32
|
+
value,
|
|
33
|
+
dispose,
|
|
34
|
+
async
|
|
35
|
+
});
|
|
36
|
+
} else if (async) env.stack.push({ async: true });
|
|
37
|
+
return value;
|
|
38
|
+
};
|
|
39
|
+
var __disposeResources = (function(SuppressedError) {
|
|
40
|
+
return function(env) {
|
|
41
|
+
function fail(e) {
|
|
42
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
43
|
+
env.hasError = true;
|
|
44
|
+
}
|
|
45
|
+
var r, s = 0;
|
|
46
|
+
function next() {
|
|
47
|
+
while (r = env.stack.pop()) try {
|
|
48
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
49
|
+
if (r.dispose) {
|
|
50
|
+
var result = r.dispose.call(r.value);
|
|
51
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
|
|
52
|
+
fail(e);
|
|
53
|
+
return next();
|
|
54
|
+
});
|
|
55
|
+
} else s |= 1;
|
|
56
|
+
} catch (e) {
|
|
57
|
+
fail(e);
|
|
58
|
+
}
|
|
59
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
60
|
+
if (env.hasError) throw env.error;
|
|
61
|
+
}
|
|
62
|
+
return next();
|
|
63
|
+
};
|
|
64
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
|
|
65
|
+
var e = new Error(message);
|
|
66
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
67
|
+
});
|
|
68
|
+
const TRUNCATED_MESSAGE = "<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>";
|
|
69
|
+
const LOST_PREFIX_MESSAGE = "<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\n";
|
|
70
|
+
const SHELL_RESET_MESSAGE = "The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.";
|
|
71
|
+
const TIMEOUT_STATUS_MARKER = "[Command timed out or OOM]";
|
|
72
|
+
const TIMEOUT_CODE = "PERSISTENT_BASH_TIMEOUT";
|
|
73
|
+
const SCROLLBACK_PAGE_LINES = 1e3;
|
|
74
|
+
const POLL_INTERVAL_MS = 25;
|
|
75
|
+
const DEFAULT_DESCRIPTION = "Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.";
|
|
76
|
+
function maybeTruncate(content, maxOutputChars, incomplete = false) {
|
|
77
|
+
if (content.length <= maxOutputChars && !incomplete) return content;
|
|
78
|
+
return content.length <= maxOutputChars ? content + TRUNCATED_MESSAGE : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE;
|
|
79
|
+
}
|
|
80
|
+
function markers() {
|
|
81
|
+
const nonce = randomUUID();
|
|
82
|
+
return {
|
|
83
|
+
start: `__QILIN_PERSISTENT_BASH_START_${nonce}__`,
|
|
84
|
+
end: `__QILIN_PERSISTENT_BASH_END_${nonce}:`
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function quoteForBash(value) {
|
|
88
|
+
return `$'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'").replaceAll("\r", "\\r").replaceAll("\n", "\\n")}'`;
|
|
89
|
+
}
|
|
90
|
+
function wrapCommand(command, marker) {
|
|
91
|
+
return `printf '%s\\n' ${quoteForBash(marker.start)}; eval -- ${quoteForBash(command)}; __qilin_persistent_bash_status=$?; printf '%s%s\\n' ${quoteForBash(marker.end)} "$__qilin_persistent_bash_status"`;
|
|
92
|
+
}
|
|
93
|
+
function trimTrailingNewline(text) {
|
|
94
|
+
return text.replace(/(?:\r?\n)+$/, "");
|
|
95
|
+
}
|
|
96
|
+
function commandOutput(snapshot, marker) {
|
|
97
|
+
const text = snapshot.text;
|
|
98
|
+
const end = text.lastIndexOf(marker.end);
|
|
99
|
+
const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1];
|
|
100
|
+
if (status === void 0) return void 0;
|
|
101
|
+
const startMarker = text.lastIndexOf(marker.start, end);
|
|
102
|
+
const start = startMarker < 0 ? 0 : startMarker + marker.start.length;
|
|
103
|
+
return {
|
|
104
|
+
text: trimTrailingNewline(text.slice(start, end).replace(/^\r?\n/, "")),
|
|
105
|
+
incomplete: startMarker < 0,
|
|
106
|
+
exitCode: Number(status)
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function partialOutput(snapshot, marker, fallback, fallbackTruncated = false) {
|
|
110
|
+
const startMarker = snapshot.text.lastIndexOf(marker.start);
|
|
111
|
+
if (startMarker >= 0) return {
|
|
112
|
+
text: trimTrailingNewline(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, "")),
|
|
113
|
+
incomplete: false
|
|
114
|
+
};
|
|
115
|
+
const fallbackStart = fallback.lastIndexOf(marker.start);
|
|
116
|
+
const afterStart = fallbackStart < 0 ? fallback : fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, "");
|
|
117
|
+
const fallbackEnd = afterStart.lastIndexOf(marker.end);
|
|
118
|
+
return {
|
|
119
|
+
text: trimTrailingNewline(fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)),
|
|
120
|
+
incomplete: fallbackTruncated || fallbackStart < 0
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
async function pause() {
|
|
124
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
125
|
+
}
|
|
126
|
+
function nextScrollbackOffset(page, offset) {
|
|
127
|
+
if (page.text.length === 0 || page.lineEnd <= offset) return void 0;
|
|
128
|
+
return page.lineEnd;
|
|
129
|
+
}
|
|
130
|
+
function retainedScrollback(ctx, owner, id, latest = ctx.terminals.read(owner, id, {
|
|
131
|
+
offset: 0,
|
|
132
|
+
count: SCROLLBACK_PAGE_LINES
|
|
133
|
+
})) {
|
|
134
|
+
const pages = latest.text.length === 0 ? [] : [latest.text];
|
|
135
|
+
let offset = latest.lineEnd;
|
|
136
|
+
let truncated = latest.truncated;
|
|
137
|
+
while (true) {
|
|
138
|
+
if (offset >= latest.totalLines) break;
|
|
139
|
+
const page = ctx.terminals.read(owner, id, {
|
|
140
|
+
offset,
|
|
141
|
+
count: SCROLLBACK_PAGE_LINES
|
|
142
|
+
});
|
|
143
|
+
truncated ||= page.truncated;
|
|
144
|
+
if (page.text.length > 0) pages.unshift(page.text);
|
|
145
|
+
const next = nextScrollbackOffset(page, offset);
|
|
146
|
+
if (next === void 0 || next >= page.totalLines) break;
|
|
147
|
+
offset = next;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
text: pages.join("\n"),
|
|
151
|
+
truncated
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function renderCaptured(output, maxOutputChars) {
|
|
155
|
+
const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete);
|
|
156
|
+
return appendStatusMarker(output.incomplete && output.text.length > 0 ? LOST_PREFIX_MESSAGE + rendered : rendered, output.exitCode !== void 0 ? `[Command finished with exit code ${output.exitCode}]` : void 0);
|
|
157
|
+
}
|
|
158
|
+
function appendStatusMarker(content, marker) {
|
|
159
|
+
if (marker === void 0) return content;
|
|
160
|
+
return content.length === 0 ? marker : `${content}\n${marker}`;
|
|
161
|
+
}
|
|
162
|
+
function renderShellExitStatus(content, exitCode, signal) {
|
|
163
|
+
return appendStatusMarker(content, signal !== null ? `[shell killed by signal: ${signal}]` : exitCode !== null ? `[shell exited: code ${exitCode}]` : "[shell exited]");
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Render the exited-session result, reset the owner's shell, and reset the
|
|
167
|
+
* message that tells the model the next call starts fresh.
|
|
168
|
+
* @param shells - the owner-scoped registry to reset.
|
|
169
|
+
* @param status - the exited session status (exit code and signal).
|
|
170
|
+
* @returns the complete model-facing result.
|
|
171
|
+
*/
|
|
172
|
+
async function respondToSessionExit(ctx, shells, owner, id, status, marker, fallback, fallbackTruncated, config) {
|
|
173
|
+
const snapshot = retainedScrollback(ctx, owner, id);
|
|
174
|
+
await shells.reset(owner, "persistent bash shell exited");
|
|
175
|
+
return [renderShellExitStatus(renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), status.exitCode, status.signal), SHELL_RESET_MESSAGE].filter((part) => part.length > 0).join("\n");
|
|
176
|
+
}
|
|
177
|
+
function persistentShells(ctx, config) {
|
|
178
|
+
const pending = /* @__PURE__ */ new WeakMap();
|
|
179
|
+
const live = /* @__PURE__ */ new Map();
|
|
180
|
+
const creating = /* @__PURE__ */ new Set();
|
|
181
|
+
const ownerCleanupInstalled = /* @__PURE__ */ new WeakSet();
|
|
182
|
+
const lifecycle = new AbortController();
|
|
183
|
+
const close = async (owner, id, reason) => {
|
|
184
|
+
if (!ctx.terminals.list(owner).some((snapshot) => snapshot.sessionId === id)) return;
|
|
185
|
+
await ctx.terminals.kill(owner, id, reason);
|
|
186
|
+
};
|
|
187
|
+
ctx.effect(() => async () => {
|
|
188
|
+
lifecycle.abort(/* @__PURE__ */ new Error("tool-bash-persistent disposed during shell creation"));
|
|
189
|
+
await Promise.allSettled([...creating]);
|
|
190
|
+
const closing = [...live].map(async ([owner, id]) => {
|
|
191
|
+
await close(owner, id, "tool-bash-persistent disposed");
|
|
192
|
+
});
|
|
193
|
+
await Promise.all(closing);
|
|
194
|
+
live.clear();
|
|
195
|
+
}, "tool-bash-persistent shell cleanup");
|
|
196
|
+
const reset = async (owner, reason) => {
|
|
197
|
+
pending.delete(owner);
|
|
198
|
+
const id = live.get(owner);
|
|
199
|
+
live.delete(owner);
|
|
200
|
+
if (id !== void 0) await close(owner, id, reason);
|
|
201
|
+
};
|
|
202
|
+
const get = (owner, signal) => {
|
|
203
|
+
const existing = pending.get(owner);
|
|
204
|
+
if (existing !== void 0) return existing;
|
|
205
|
+
const combinedSignal = AbortSignal.any([signal, lifecycle.signal]);
|
|
206
|
+
const tracked = (async () => {
|
|
207
|
+
try {
|
|
208
|
+
const cwd = owner.session.header.cwd;
|
|
209
|
+
const spawned = await ctx.terminals.spawn(owner, {
|
|
210
|
+
type: config.backendType,
|
|
211
|
+
...cwd === void 0 ? {} : { cwd }
|
|
212
|
+
}, combinedSignal);
|
|
213
|
+
live.set(owner, spawned.sessionId);
|
|
214
|
+
if (!ownerCleanupInstalled.has(owner)) {
|
|
215
|
+
ownerCleanupInstalled.add(owner);
|
|
216
|
+
owner.ctx.effect(() => () => {
|
|
217
|
+
pending.delete(owner);
|
|
218
|
+
live.delete(owner);
|
|
219
|
+
}, "tool-bash-persistent owner cache cleanup");
|
|
220
|
+
}
|
|
221
|
+
const result = await ctx.terminals.startSend(owner, spawned.sessionId, {
|
|
222
|
+
text: "stty -echo",
|
|
223
|
+
submit: true,
|
|
224
|
+
signal: combinedSignal
|
|
225
|
+
}).done;
|
|
226
|
+
if (result.sessionStatus.kind === "exited" || result.waitReason === "timeout") throw new Error("persistent bash shell did not accept initialization");
|
|
227
|
+
return spawned.sessionId;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
await reset(owner, "persistent bash initialization failed");
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
})().finally(() => {
|
|
233
|
+
creating.delete(tracked);
|
|
234
|
+
});
|
|
235
|
+
creating.add(tracked);
|
|
236
|
+
pending.set(owner, tracked);
|
|
237
|
+
return tracked;
|
|
238
|
+
};
|
|
239
|
+
return {
|
|
240
|
+
get,
|
|
241
|
+
reset
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
async function executeCommand(ctx, shells, owner, command, config, upstream) {
|
|
245
|
+
const env_1 = {
|
|
246
|
+
stack: [],
|
|
247
|
+
error: void 0,
|
|
248
|
+
hasError: false
|
|
249
|
+
};
|
|
250
|
+
try {
|
|
251
|
+
const commandDeadline = __addDisposableResource(env_1, deadline(upstream, config.timeoutMs, TIMEOUT_CODE), false);
|
|
252
|
+
let id;
|
|
253
|
+
try {
|
|
254
|
+
id = await shells.get(owner, commandDeadline.signal);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (upstream.aborted && error === upstream.reason) return "";
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
const marker = markers();
|
|
260
|
+
const wrapped = wrapCommand(command, marker);
|
|
261
|
+
let first = true;
|
|
262
|
+
let fallback = "";
|
|
263
|
+
let fallbackTruncated = false;
|
|
264
|
+
while (true) {
|
|
265
|
+
const status = ctx.terminals.list(owner).find((session) => session.sessionId === id)?.status;
|
|
266
|
+
if (status?.kind === "exited") return await respondToSessionExit(ctx, shells, owner, id, status, marker, fallback, fallbackTruncated, config);
|
|
267
|
+
let operation;
|
|
268
|
+
let result;
|
|
269
|
+
try {
|
|
270
|
+
operation = ctx.terminals.startSend(owner, id, {
|
|
271
|
+
text: first ? wrapped : "",
|
|
272
|
+
submit: first,
|
|
273
|
+
signal: commandDeadline.signal
|
|
274
|
+
});
|
|
275
|
+
first = false;
|
|
276
|
+
result = await operation.done;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
await shells.reset(owner, "persistent bash send failed");
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
const incremental = operation.readOutput();
|
|
282
|
+
fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport;
|
|
283
|
+
fallbackTruncated ||= incremental.truncated || result.truncated;
|
|
284
|
+
const latest = ctx.terminals.read(owner, id, {
|
|
285
|
+
offset: 0,
|
|
286
|
+
count: SCROLLBACK_PAGE_LINES
|
|
287
|
+
});
|
|
288
|
+
const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE);
|
|
289
|
+
if (timedOut !== void 0) {
|
|
290
|
+
const partial = renderCaptured(partialOutput(retainedScrollback(ctx, owner, id, latest), marker, fallback, fallbackTruncated), config.maxOutputChars);
|
|
291
|
+
await shells.reset(owner, "persistent bash command timed out");
|
|
292
|
+
return [
|
|
293
|
+
`Your command timed out after ${Math.round(timedOut.timeoutMs / 1e3)} seconds or experienced an OOM error. Below is partial output:`,
|
|
294
|
+
appendStatusMarker(partial, TIMEOUT_STATUS_MARKER),
|
|
295
|
+
SHELL_RESET_MESSAGE
|
|
296
|
+
].join("\n");
|
|
297
|
+
}
|
|
298
|
+
if (commandDeadline.signal.aborted) {
|
|
299
|
+
await shells.reset(owner, "persistent bash command aborted");
|
|
300
|
+
return "";
|
|
301
|
+
}
|
|
302
|
+
if (latest.text.includes(marker.end)) {
|
|
303
|
+
const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker);
|
|
304
|
+
if (complete !== void 0) return renderCaptured(complete, config.maxOutputChars);
|
|
305
|
+
}
|
|
306
|
+
if (result.sessionStatus.kind === "exited") return await respondToSessionExit(ctx, shells, owner, id, result.sessionStatus, marker, fallback, fallbackTruncated, config);
|
|
307
|
+
if (result.waitReason === "stdin_read") return renderCaptured(partialOutput(retainedScrollback(ctx, owner, id, latest), marker, fallback, fallbackTruncated), config.maxOutputChars);
|
|
308
|
+
await pause();
|
|
309
|
+
}
|
|
310
|
+
} catch (e_1) {
|
|
311
|
+
env_1.error = e_1;
|
|
312
|
+
env_1.hasError = true;
|
|
313
|
+
} finally {
|
|
314
|
+
__disposeResources(env_1);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Register the model-facing persistent `bash` tool.
|
|
319
|
+
* @param ctx - plugin context carrying tools and the owner-scoped PTY service.
|
|
320
|
+
* @param config - selected PTY backend and command deadline.
|
|
321
|
+
*/
|
|
322
|
+
function registerPersistentBash(ctx, config) {
|
|
323
|
+
const shells = persistentShells(ctx, config);
|
|
324
|
+
const queues = /* @__PURE__ */ new WeakMap();
|
|
325
|
+
const serialized = async (owner, operation) => {
|
|
326
|
+
const run = (queues.get(owner) ?? Promise.resolve()).then(operation, operation);
|
|
327
|
+
const tail = run.then(() => void 0, () => void 0);
|
|
328
|
+
queues.set(owner, tail);
|
|
329
|
+
try {
|
|
330
|
+
return await run;
|
|
331
|
+
} finally {
|
|
332
|
+
if (queues.get(owner) === tail) queues.delete(owner);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
ctx.tools.register(defineTool({
|
|
336
|
+
name: "bash",
|
|
337
|
+
description: config.description,
|
|
338
|
+
parameters: { command: {
|
|
339
|
+
type: "string",
|
|
340
|
+
required: true,
|
|
341
|
+
description: "The bash command to run. Relative path is preferred in the command."
|
|
342
|
+
} },
|
|
343
|
+
output: {
|
|
344
|
+
schema: { type: "string" },
|
|
345
|
+
render: (_args, value) => [{
|
|
346
|
+
type: "text",
|
|
347
|
+
text: value
|
|
348
|
+
}]
|
|
349
|
+
},
|
|
350
|
+
async execute(args, exec) {
|
|
351
|
+
if (args.command.trim().length === 0) throw new Error("command must be a non-empty string");
|
|
352
|
+
const owner = exec.agent;
|
|
353
|
+
if (owner === void 0) throw new Error("bash requires an owning agent session");
|
|
354
|
+
return serialized(owner, async () => {
|
|
355
|
+
if (exec.signal.aborted) return "";
|
|
356
|
+
return executeCommand(ctx, shells, owner, args.command, config, exec.signal);
|
|
357
|
+
});
|
|
358
|
+
},
|
|
359
|
+
presentCall: (args) => ({
|
|
360
|
+
card: "terminal",
|
|
361
|
+
title: args.command
|
|
362
|
+
})
|
|
363
|
+
}));
|
|
364
|
+
}
|
|
365
|
+
const name = "tool-bash-persistent";
|
|
366
|
+
const inject = ["tools", "terminals"];
|
|
367
|
+
/** Runtime configuration schema for the persistent Bash tool. */
|
|
368
|
+
const Config = z.object({
|
|
369
|
+
backendType: z.string().default("shell"),
|
|
370
|
+
timeoutMs: z.number().default(3e5),
|
|
371
|
+
maxOutputChars: z.number().default(16e3),
|
|
372
|
+
description: z.string().default(DEFAULT_DESCRIPTION)
|
|
373
|
+
});
|
|
374
|
+
/** Register one owner-scoped persistent `bash` tool. */
|
|
375
|
+
function apply(ctx, config) {
|
|
376
|
+
const resolved = {
|
|
377
|
+
backendType: config.backendType ?? "shell",
|
|
378
|
+
timeoutMs: config.timeoutMs ?? 3e5,
|
|
379
|
+
maxOutputChars: config.maxOutputChars ?? 16e3,
|
|
380
|
+
description: config.description ?? DEFAULT_DESCRIPTION
|
|
381
|
+
};
|
|
382
|
+
if (resolved.backendType.trim().length === 0) throw new Error("tool-bash-persistent: backendType must be non-empty");
|
|
383
|
+
if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0) throw new Error("tool-bash-persistent: timeoutMs must be a positive safe integer");
|
|
384
|
+
if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) throw new Error("tool-bash-persistent: maxOutputChars must be a positive safe integer");
|
|
385
|
+
if (resolved.description.trim().length === 0) throw new Error("tool-bash-persistent: description must be non-empty");
|
|
386
|
+
registerPersistentBash(ctx, resolved);
|
|
387
|
+
}
|
|
388
|
+
//#endregion
|
|
389
|
+
export { Config, apply, inject, name };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing persistent `bash` tool over the owner-scoped PTY seam.
|
|
3
|
+
* @module @qilin-agent/tool-bash-persistent
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@qilin-agent/kylin';
|
|
6
|
+
import z from '@qilin-agent/schemastery';
|
|
7
|
+
export declare const name = "tool-bash-persistent";
|
|
8
|
+
export declare const inject: string[];
|
|
9
|
+
/** Configuration for the persistent Bash tool. */
|
|
10
|
+
export interface Config {
|
|
11
|
+
/** PTY backend used for each owner-isolated persistent shell (default `shell`). */
|
|
12
|
+
backendType?: string;
|
|
13
|
+
/** Wall-clock limit for one command (default 300000). */
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
/** Maximum returned command-output characters before clipping (default 16000). */
|
|
16
|
+
maxOutputChars?: number;
|
|
17
|
+
/** Model-facing tool description; deployments may describe their environment. */
|
|
18
|
+
description?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Runtime configuration schema for the persistent Bash tool. */
|
|
21
|
+
export declare const Config: z<Config>;
|
|
22
|
+
/** Register one owner-scoped persistent `bash` tool. */
|
|
23
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
24
|
+
//# sourceMappingURL=index.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qilin-agent/tool-bash-persistent",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service",
|
|
4
|
+
"version": "3.1.3",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kkutysllb/QiLin.git",
|
|
11
|
+
"directory": "packages/shell/tool-bash-persistent"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "lib/index.js",
|
|
18
|
+
"types": "lib/types/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./lib/types/index.d.ts",
|
|
22
|
+
"default": "./lib/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"lib/index.js",
|
|
28
|
+
"lib/types/**/*.d.ts"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@qilin-agent/agent": "3.1.3",
|
|
33
|
+
"@qilin-agent/timeout": "3.1.3",
|
|
34
|
+
"@qilin-agent/tools": "3.1.3",
|
|
35
|
+
"@qilin-agent/kylin": "~4.0.3",
|
|
36
|
+
"@qilin-agent/terminal": "3.1.3"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@qilin-agent/schemastery": "~3.18.3"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@qilin-agent/kylin-plugin-include": "~1.0.8",
|
|
43
|
+
"@qilin-agent/kylin-plugin-loader": "~1.0.4",
|
|
44
|
+
"@qilin-agent/agent": "3.1.3",
|
|
45
|
+
"@qilin-agent/agent-loop-testkit": "3.1.3",
|
|
46
|
+
"@qilin-agent/llm": "3.1.3",
|
|
47
|
+
"@qilin-agent/terminal": "3.1.3",
|
|
48
|
+
"@qilin-agent/terminal-bash": "3.1.3",
|
|
49
|
+
"@qilin-agent/sandbox": "3.1.3",
|
|
50
|
+
"@qilin-agent/sandbox-policy": "3.1.3",
|
|
51
|
+
"@qilin-agent/session": "3.1.3",
|
|
52
|
+
"@qilin-agent/system-prompt": "3.1.3",
|
|
53
|
+
"@qilin-agent/subprocess-local": "3.1.3",
|
|
54
|
+
"@qilin-agent/kylin": "~4.0.3",
|
|
55
|
+
"@qilin-agent/timeout": "3.1.3",
|
|
56
|
+
"@qilin-agent/session-projection": "3.1.3",
|
|
57
|
+
"@qilin-agent/tools": "3.1.3"
|
|
58
|
+
}
|
|
6
59
|
}
|