magic-agent 1.0.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/LICENSE +21 -0
- package/README.md +392 -0
- package/cmd/magic-agent/main.go +12 -0
- package/cmd/magic-client/main.go +144 -0
- package/go.mod +11 -0
- package/go.sum +12 -0
- package/internal/agent/arkclaw.go +587 -0
- package/internal/agent/arkclaw_stream.go +312 -0
- package/internal/agent/arkclaw_stream_test.go +428 -0
- package/internal/agent/arkclaw_test.go +827 -0
- package/internal/agent/ask.go +694 -0
- package/internal/agent/ask_test.go +587 -0
- package/internal/agent/attachments.go +200 -0
- package/internal/agent/attachments_test.go +481 -0
- package/internal/agent/capability.go +154 -0
- package/internal/agent/capability_test.go +190 -0
- package/internal/agent/claude.go +406 -0
- package/internal/agent/codebuddy.go +1044 -0
- package/internal/agent/codebuddy_account_test.go +128 -0
- package/internal/agent/codebuddy_gateway.go +479 -0
- package/internal/agent/codebuddy_gateway_stream.go +358 -0
- package/internal/agent/codebuddy_gateway_test.go +673 -0
- package/internal/agent/codex.go +599 -0
- package/internal/agent/codex_home.go +206 -0
- package/internal/agent/codex_home_test.go +130 -0
- package/internal/agent/codex_stream_test.go +97 -0
- package/internal/agent/codex_test.go +280 -0
- package/internal/agent/custom_models.go +197 -0
- package/internal/agent/custom_models_test.go +283 -0
- package/internal/agent/dsh.go +754 -0
- package/internal/agent/dsh_sdk.go +679 -0
- package/internal/agent/dsh_sdk_test.go +726 -0
- package/internal/agent/dsh_test.go +759 -0
- package/internal/agent/engine.go +469 -0
- package/internal/agent/engine_base.go +497 -0
- package/internal/agent/engine_base_test.go +308 -0
- package/internal/agent/engine_test.go +858 -0
- package/internal/agent/env.go +88 -0
- package/internal/agent/env_test.go +106 -0
- package/internal/agent/jsonextract.go +128 -0
- package/internal/agent/jsonextract_test.go +110 -0
- package/internal/agent/keepalive_test.go +216 -0
- package/internal/agent/llmengine.go +470 -0
- package/internal/agent/llmengine_test.go +551 -0
- package/internal/agent/llmhttp.go +437 -0
- package/internal/agent/llmhttp_test.go +304 -0
- package/internal/agent/llmmodels.go +205 -0
- package/internal/agent/login.go +27 -0
- package/internal/agent/main_test.go +23 -0
- package/internal/agent/models.go +686 -0
- package/internal/agent/models_test.go +877 -0
- package/internal/agent/openclaw.go +543 -0
- package/internal/agent/openclaw_acp.go +949 -0
- package/internal/agent/openclaw_acp_test.go +406 -0
- package/internal/agent/openclaw_test.go +451 -0
- package/internal/agent/output.go +191 -0
- package/internal/agent/permission.go +362 -0
- package/internal/agent/permission_test.go +526 -0
- package/internal/agent/prompt.go +65 -0
- package/internal/agent/runcmd.go +259 -0
- package/internal/agent/runcmd_test.go +81 -0
- package/internal/agent/runcmd_unix.go +56 -0
- package/internal/agent/runcmd_windows.go +66 -0
- package/internal/agent/runner.go +180 -0
- package/internal/agent/runner_test.go +263 -0
- package/internal/agent/spawnhook_test.go +96 -0
- package/internal/agent/stream.go +882 -0
- package/internal/agent/stream_test.go +565 -0
- package/internal/agent/streamjson.go +185 -0
- package/internal/agent/tags.go +52 -0
- package/internal/agent/toolkind.go +96 -0
- package/internal/agent/toolkind_test.go +70 -0
- package/internal/agent/tools_suffix_test.go +261 -0
- package/internal/agent/trae.go +342 -0
- package/internal/agent/workspace_test.go +166 -0
- package/internal/boundary/boundary_test.go +165 -0
- package/internal/cli/ask.go +1113 -0
- package/internal/cli/ask_test.go +1088 -0
- package/internal/cli/attach_test.go +239 -0
- package/internal/cli/contract_test.go +157 -0
- package/internal/cli/control.go +149 -0
- package/internal/cli/control_test.go +192 -0
- package/internal/cli/engines_test.go +205 -0
- package/internal/cli/events.go +198 -0
- package/internal/cli/events_test.go +166 -0
- package/internal/cli/keepalive_test.go +391 -0
- package/internal/cli/login.go +78 -0
- package/internal/cli/permission_test.go +295 -0
- package/internal/cli/prompt_source_test.go +222 -0
- package/internal/cli/root.go +752 -0
- package/internal/cli/session.go +486 -0
- package/internal/cli/session_test.go +202 -0
- package/internal/cli/sessionlog.go +325 -0
- package/internal/cli/sessionlog_command_test.go +204 -0
- package/internal/cli/sessionlog_test.go +262 -0
- package/internal/cli/sessionlog_user_test.go +129 -0
- package/internal/cli/toolkind_wire_test.go +84 -0
- package/internal/cli/workspace_test.go +188 -0
- package/internal/client/cors_test.go +136 -0
- package/internal/client/lan.go +68 -0
- package/internal/client/lan_test.go +53 -0
- package/internal/client/pair.go +163 -0
- package/internal/client/pair_test.go +109 -0
- package/internal/client/profile.go +184 -0
- package/internal/client/service.go +888 -0
- package/internal/client/service_test.go +654 -0
- package/internal/client/session_events_test.go +186 -0
- package/internal/client/session_messages_test.go +150 -0
- package/internal/config/config.go +439 -0
- package/internal/config/config_test.go +448 -0
- package/internal/session/session.go +447 -0
- package/internal/session/session_append.go +241 -0
- package/internal/session/session_append_test.go +161 -0
- package/internal/session/session_kill_unix_test.go +90 -0
- package/internal/session/session_mark_test.go +95 -0
- package/internal/session/session_test.go +237 -0
- package/internal/session/sessionlog.go +128 -0
- package/internal/session/sessionlog_read_test.go +157 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/install.js +121 -0
- package/npm/lib/platform.js +78 -0
- package/package.json +36 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// Package session - 会话登记表:让「停止指定会话」成为可能。
|
|
2
|
+
//
|
|
3
|
+
// 用户需求(2026-09-18):「要支持停止指定会话」。
|
|
4
|
+
//
|
|
5
|
+
// 为什么需要一张登记表:magic-agent 把引擎 CLI 放在**独立进程组**里跑
|
|
6
|
+
// (Setpgid,见 agent/runcmd.go),所以它和 magic-agent 自己不在同一组 ——
|
|
7
|
+
// 调用方(观物台等)`kill(-magic-agent-pid)` 带不走引擎子进程,会出现
|
|
8
|
+
// 「界面已停止、引擎还在跑」。更麻烦的是进程句柄只活在调用方内存里:
|
|
9
|
+
// 应用重启后,之前 detach 出去的会话就再也停不掉了。
|
|
10
|
+
//
|
|
11
|
+
// 于是每次调用都落一条记录(引擎子进程 pid + 引擎/模型/工作目录 + 会话 id),
|
|
12
|
+
// 之后任何进程都能用 `magic-agent --stop <session_id|run_id>` 精确停掉它。
|
|
13
|
+
//
|
|
14
|
+
// 文件位置:$MAGIC_AGENT_SESSIONS > ~/.magic-agent/sessions/<run_id>.json
|
|
15
|
+
// 每条一个文件(无锁、无共享写),过期记录(默认 24h)在 Begin/List/Stop 时顺手清理。
|
|
16
|
+
//
|
|
17
|
+
// 记录里 session_id 可能一开始不知道:引擎的会话 id 只在**收尾行**里给
|
|
18
|
+
// (见 magic-agent 的 result envelope),所以:
|
|
19
|
+
//
|
|
20
|
+
// 开始时就已知(--session <id> 续接)→ 直接写进记录
|
|
21
|
+
// 开始时未知(新会话) → 结束时回填(Finish),期间用 run_id 寻址
|
|
22
|
+
//
|
|
23
|
+
// 因此 --stop 接受两种 id:会话 id(session_id)或运行 id(run_id,`--sessions` 可见)。
|
|
24
|
+
package session
|
|
25
|
+
|
|
26
|
+
import (
|
|
27
|
+
"encoding/json"
|
|
28
|
+
"fmt"
|
|
29
|
+
"os"
|
|
30
|
+
"path/filepath"
|
|
31
|
+
"sort"
|
|
32
|
+
"strconv"
|
|
33
|
+
"strings"
|
|
34
|
+
"time"
|
|
35
|
+
|
|
36
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
// EnvDir 覆盖登记表目录的环境变量名。
|
|
40
|
+
const EnvDir = "MAGIC_AGENT_SESSIONS"
|
|
41
|
+
|
|
42
|
+
// Retention 记录保留时长:过期的(按 UpdatedAt)在每次读写时清理。
|
|
43
|
+
// 留着而不是用完即删,是为了让「停一条早就结束的会话」能答出「已结束」而不是「没找到」。
|
|
44
|
+
const Retention = 24 * time.Hour
|
|
45
|
+
|
|
46
|
+
// 记录状态。
|
|
47
|
+
//
|
|
48
|
+
// ⚠️ 词表对齐 agents-anywhere 的 `TimelineStatus`(2026-09-29):同一个意思在两个
|
|
49
|
+
// 界面上说同一个词,才谈得上「统一」。**只统一会话侧**(这个包的记录回答的是
|
|
50
|
+
// 「一条会话活得怎么样」);需求看板那套(待验证 / 已完成…)是另一个轴,不动。
|
|
51
|
+
//
|
|
52
|
+
// 三处细分与 anywhere 对齐(此前都挤在 stopped / failed 里):
|
|
53
|
+
//
|
|
54
|
+
// · cancelled 被 `--stop` 停掉 —— 调用方**主动取消**这条会话
|
|
55
|
+
// · interrupted 用户**中途打断当前轮**(`--control interrupt`),会话本身仍可继续
|
|
56
|
+
// · waiting_approval 引擎停下来等用户(提问 / 工具待授权)—— 会话还活着,但没在跑,
|
|
57
|
+
// 界面上该显示「等审批」而不是「生成中」
|
|
58
|
+
const (
|
|
59
|
+
StateRunning = "running" // 正在跑
|
|
60
|
+
StateWaitingApproval = "waiting_approval" // 卡在等授权 / 等用户回答
|
|
61
|
+
StateDone = "done" // 正常结束
|
|
62
|
+
StateFailed = "failed" // 报错结束
|
|
63
|
+
StateCancelled = "cancelled" // 被 --stop 取消
|
|
64
|
+
StateInterrupted = "interrupted" // 当前轮被打断(会话仍可继续)
|
|
65
|
+
StateGone = "gone" // 记录里是 running,但进程已不在(自然退出/被别处杀掉)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
// normalizeState 把磁盘上的**旧状态值**归一成当前词表。
|
|
69
|
+
//
|
|
70
|
+
// 为什么必须有:`stopped` 在 2026-09-29 之前由 `--stop` 写入,一次性全部改写磁盘上的
|
|
71
|
+
// 记录既没必要也不安全(还有别的版本在读同一份目录)。所以在**读**的时候翻。
|
|
72
|
+
// 认不出来的值原样返回 —— 宁可显示一个陌生词,也不要把它悄悄改成别的意思。
|
|
73
|
+
func normalizeState(s string) string {
|
|
74
|
+
if s == "stopped" {
|
|
75
|
+
return StateCancelled
|
|
76
|
+
}
|
|
77
|
+
return s
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// isTerminalOverride 这个已落盘的状态是否**优先于**收尾时的 failed。
|
|
81
|
+
// 它记的是「谁、以什么方式结束了它」,比一句 failed 信息量更大。
|
|
82
|
+
// (readRecord 已把老值 stopped 归一成 cancelled,这里不必再认 stopped。)
|
|
83
|
+
func isTerminalOverride(s string) bool {
|
|
84
|
+
return s == StateCancelled || s == StateInterrupted
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Record 一条会话记录(也是磁盘上的 JSON 形状)。
|
|
88
|
+
type Record struct {
|
|
89
|
+
RunID string `json:"run_id"`
|
|
90
|
+
SessionID string `json:"session_id,omitempty"`
|
|
91
|
+
PID int `json:"pid"`
|
|
92
|
+
KillGroup bool `json:"kill_group"` // true = 按进程组杀(引擎子进程自成组)
|
|
93
|
+
Engine string `json:"engine"`
|
|
94
|
+
Model string `json:"model,omitempty"`
|
|
95
|
+
Workspace string `json:"workspace,omitempty"`
|
|
96
|
+
PromptHead string `json:"prompt_head,omitempty"`
|
|
97
|
+
State string `json:"state"`
|
|
98
|
+
StartedAt time.Time `json:"started_at"`
|
|
99
|
+
UpdatedAt time.Time `json:"updated_at"`
|
|
100
|
+
|
|
101
|
+
// Append 追加入口的 unix socket 路径(常驻会话 / --keep-alive 才有)。
|
|
102
|
+
// 空 = 该会话不可追加(--append 会明确告知原因,不静默)。
|
|
103
|
+
Append string `json:"append,omitempty"`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Dir 返回登记表目录($MAGIC_AGENT_SESSIONS 优先)。
|
|
107
|
+
func Dir() string {
|
|
108
|
+
if p := strings.TrimSpace(os.Getenv(EnvDir)); p != "" {
|
|
109
|
+
return p
|
|
110
|
+
}
|
|
111
|
+
home, err := os.UserHomeDir()
|
|
112
|
+
if err != nil || home == "" {
|
|
113
|
+
return ""
|
|
114
|
+
}
|
|
115
|
+
return filepath.Join(home, ".magic-agent", "sessions")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// BeginOptions 开始一条会话记录所需的输入。
|
|
119
|
+
type BeginOptions struct {
|
|
120
|
+
Engine string
|
|
121
|
+
Model string
|
|
122
|
+
Workspace string
|
|
123
|
+
SessionID string // 续接时已知;新会话为空
|
|
124
|
+
PromptHead string // 提示词开头(便于人辨认)
|
|
125
|
+
PID int // 可先填自身 pid 兜底(引擎子进程起来后会被 SetPID 覆盖)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Handle 一条运行中会话的句柄(负责回写 pid 与收尾状态)。
|
|
129
|
+
type Handle struct {
|
|
130
|
+
rec Record
|
|
131
|
+
path string
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// RunID 本次运行的 id。
|
|
135
|
+
func (h *Handle) RunID() string { return h.rec.RunID }
|
|
136
|
+
|
|
137
|
+
// Record 当前记录快照。
|
|
138
|
+
func (h *Handle) Record() Record { return h.rec }
|
|
139
|
+
|
|
140
|
+
// SetPID 记录引擎子进程 pid(由 agent.WithSpawnHook 在 Start 之后回调)。
|
|
141
|
+
// group=true 表示该 pid 自成进程组(引擎 CLI 的正常情况)。
|
|
142
|
+
func (h *Handle) SetPID(pid int, group bool) {
|
|
143
|
+
if h == nil || pid <= 0 {
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
h.rec.PID = pid
|
|
147
|
+
h.rec.KillGroup = group
|
|
148
|
+
h.write()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// SetSessionID 回填会话 id(续接时开始就知道;新会话在收尾时才知道)。
|
|
152
|
+
func (h *Handle) SetSessionID(id string) {
|
|
153
|
+
if h == nil || strings.TrimSpace(id) == "" || h.rec.SessionID == id {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
h.rec.SessionID = strings.TrimSpace(id)
|
|
157
|
+
h.write()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Mark 就地改状态(只在 running 与 waiting_approval 之间来回时生效)。
|
|
161
|
+
//
|
|
162
|
+
// 为什么需要一个**中途**写状态的入口:引擎会停下来等用户(提问 / 工具待授权),此时这条
|
|
163
|
+
// 会话还活着,但**没在跑** —— 界面上该显示「等审批」而不是「生成中」。这是 2026-09-29
|
|
164
|
+
// 统一会话状态词表时新增的写入点(值取自 anywhere 的 waiting_approval)。
|
|
165
|
+
//
|
|
166
|
+
// ⚠️ 只允许在这两个值之间切:一旦写进终态(done / failed / cancelled / interrupted),
|
|
167
|
+
//
|
|
168
|
+
// 这里不许改回去。终态是「这条会话结束了」的结论,让一个迟到的流事件把它翻回
|
|
169
|
+
// running,等于让历史撒谎。
|
|
170
|
+
func (h *Handle) Mark(state string) {
|
|
171
|
+
if h == nil || state == "" {
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
cur := normalizeState(h.rec.State)
|
|
175
|
+
if cur != StateRunning && cur != StateWaitingApproval {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
if state != StateRunning && state != StateWaitingApproval {
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if cur == state {
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
h.rec.State = state
|
|
185
|
+
h.write()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Finish 收尾:写最终状态(done / failed / cancelled / interrupted)+ 回填会话 id。
|
|
189
|
+
//
|
|
190
|
+
// 一个刻意的小保护:被别人 `--stop` 停掉、或用户中途打断时,磁盘上已经是
|
|
191
|
+
// cancelled / interrupted;本进程随后因被杀而以 failed 收尾 —— 不能把它覆盖成
|
|
192
|
+
// failed,否则「是谁、以什么方式结束的」就丢了。
|
|
193
|
+
// ⚠️ 2026-09-29 起保护名单扩到两个值(此前只有 stopped):对齐 anywhere 的
|
|
194
|
+
//
|
|
195
|
+
// TimelineStatus 时把「取消」与「打断」分了开,保护漏一个就等于白分。
|
|
196
|
+
func (h *Handle) Finish(sessionID, state string) {
|
|
197
|
+
if h == nil {
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
if state == StateFailed {
|
|
201
|
+
if cur, err := readRecord(h.path); err == nil && isTerminalOverride(cur.State) {
|
|
202
|
+
state = cur.State
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if s := strings.TrimSpace(sessionID); s != "" {
|
|
206
|
+
h.rec.SessionID = s
|
|
207
|
+
}
|
|
208
|
+
if state != "" {
|
|
209
|
+
h.rec.State = state
|
|
210
|
+
}
|
|
211
|
+
h.write()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// KillChild 立即杀掉本条会话的进程(进程组 / 单进程),用于信号处理路径。
|
|
215
|
+
func (h *Handle) KillChild() {
|
|
216
|
+
if h == nil || h.rec.PID <= 0 {
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
_ = agent.KillPID(h.rec.PID, h.rec.KillGroup)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// write 落盘(失败静默:登记表只是可运维性,不能因为它写不动就让调用失败)。
|
|
223
|
+
func (h *Handle) write() {
|
|
224
|
+
h.rec.UpdatedAt = time.Now()
|
|
225
|
+
data, err := json.MarshalIndent(h.rec, "", " ")
|
|
226
|
+
if err != nil {
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
dir := filepath.Dir(h.path)
|
|
230
|
+
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
// 先写临时文件再改名:避免读到写了一半的 JSON。
|
|
234
|
+
tmp := h.path + ".tmp"
|
|
235
|
+
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
_ = os.Rename(tmp, h.path)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Begin 新建一条 running 记录。
|
|
242
|
+
func Begin(o BeginOptions) (*Handle, error) {
|
|
243
|
+
dir := Dir()
|
|
244
|
+
if dir == "" {
|
|
245
|
+
return nil, fmt.Errorf("cannot resolve sessions dir")
|
|
246
|
+
}
|
|
247
|
+
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
248
|
+
return nil, err
|
|
249
|
+
}
|
|
250
|
+
prune(dir)
|
|
251
|
+
|
|
252
|
+
now := time.Now()
|
|
253
|
+
rec := Record{
|
|
254
|
+
// run_id 用 base36 压短:它同时决定 socket 文件名,而 unix socket 路径有
|
|
255
|
+
// 硬上限(macOS sun_path 104 字节),长 id + 长 HOME 会直接 bind 失败。
|
|
256
|
+
RunID: "run-" + strconv.FormatInt(now.UnixNano(), 36) + "-" + strconv.FormatInt(int64(os.Getpid()), 36),
|
|
257
|
+
SessionID: strings.TrimSpace(o.SessionID),
|
|
258
|
+
PID: o.PID,
|
|
259
|
+
KillGroup: false, // 自身 pid 兜底时按单进程杀(不能杀调用方的进程组)
|
|
260
|
+
Engine: o.Engine,
|
|
261
|
+
Model: o.Model,
|
|
262
|
+
Workspace: o.Workspace,
|
|
263
|
+
PromptHead: head(o.PromptHead, 80),
|
|
264
|
+
State: StateRunning,
|
|
265
|
+
StartedAt: now,
|
|
266
|
+
UpdatedAt: now,
|
|
267
|
+
}
|
|
268
|
+
h := &Handle{rec: rec, path: filepath.Join(dir, rec.RunID+".json")}
|
|
269
|
+
h.write()
|
|
270
|
+
return h, nil
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// head 截取开头 n 个字符(按 rune,避免把中文切坏)。
|
|
274
|
+
func head(s string, n int) string {
|
|
275
|
+
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
|
|
276
|
+
r := []rune(s)
|
|
277
|
+
if len(r) <= n {
|
|
278
|
+
return s
|
|
279
|
+
}
|
|
280
|
+
return string(r[:n]) + "…"
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// List 返回全部记录(新的在前);顺带清理过期记录。
|
|
284
|
+
func List() ([]Record, error) {
|
|
285
|
+
dir := Dir()
|
|
286
|
+
if dir == "" {
|
|
287
|
+
return nil, fmt.Errorf("cannot resolve sessions dir")
|
|
288
|
+
}
|
|
289
|
+
prune(dir)
|
|
290
|
+
entries, err := os.ReadDir(dir)
|
|
291
|
+
if err != nil {
|
|
292
|
+
if os.IsNotExist(err) {
|
|
293
|
+
return nil, nil
|
|
294
|
+
}
|
|
295
|
+
return nil, err
|
|
296
|
+
}
|
|
297
|
+
out := make([]Record, 0, len(entries))
|
|
298
|
+
for _, e := range entries {
|
|
299
|
+
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
if rec, err := readRecord(filepath.Join(dir, e.Name())); err == nil {
|
|
303
|
+
out = append(out, rec)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
sort.Slice(out, func(i, j int) bool { return out[i].StartedAt.After(out[j].StartedAt) })
|
|
307
|
+
return out, nil
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Find 按 run_id 或 session_id 精确匹配一条记录。
|
|
311
|
+
// 同 id 有多条时优先「running」的那条,其次取最新。
|
|
312
|
+
func Find(id string) (Record, bool, error) {
|
|
313
|
+
want := strings.TrimSpace(id)
|
|
314
|
+
if want == "" {
|
|
315
|
+
return Record{}, false, fmt.Errorf("empty session id")
|
|
316
|
+
}
|
|
317
|
+
all, err := List()
|
|
318
|
+
if err != nil {
|
|
319
|
+
return Record{}, false, err
|
|
320
|
+
}
|
|
321
|
+
var best Record
|
|
322
|
+
found := false
|
|
323
|
+
for _, r := range all {
|
|
324
|
+
if r.RunID != want && r.SessionID != want {
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
switch {
|
|
328
|
+
case !found:
|
|
329
|
+
best, found = r, true
|
|
330
|
+
case r.State == StateRunning && best.State != StateRunning:
|
|
331
|
+
best = r
|
|
332
|
+
case r.State == best.State && r.StartedAt.After(best.StartedAt):
|
|
333
|
+
best = r
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return best, found, nil
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// StopResult 停止一条会话的结果。
|
|
340
|
+
type StopResult struct {
|
|
341
|
+
Record Record `json:"record"`
|
|
342
|
+
Stopped bool `json:"stopped"` // 是否真的发出了终止并确认进程消失
|
|
343
|
+
Reason string `json:"reason,omitempty"`
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Stop 停止指定会话:先 SIGTERM,宽限期内不退再 SIGKILL,最后回写状态。
|
|
347
|
+
//
|
|
348
|
+
// 幂等:进程已经不在了 → Stopped=false + reason(不报错),并把状态改成 gone/stopped。
|
|
349
|
+
// 找不到 id → 返回错误(多半是 id 写错了,要让调用方知道)。
|
|
350
|
+
func Stop(id string) (StopResult, error) {
|
|
351
|
+
rec, found, err := Find(id)
|
|
352
|
+
if err != nil {
|
|
353
|
+
return StopResult{}, err
|
|
354
|
+
}
|
|
355
|
+
if !found {
|
|
356
|
+
return StopResult{}, fmt.Errorf("no such session %q(--sessions 可列出全部)", id)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if rec.State != StateRunning {
|
|
360
|
+
return StopResult{Record: rec, Reason: "会话已结束(state=" + rec.State + ")"}, nil
|
|
361
|
+
}
|
|
362
|
+
if rec.PID <= 0 || !agent.PIDAlive(rec.PID) {
|
|
363
|
+
rec.State = StateGone
|
|
364
|
+
rec.UpdatedAt = time.Now()
|
|
365
|
+
writeRecord(rec)
|
|
366
|
+
return StopResult{Record: rec, Reason: "进程已不在(可能已自然结束)"}, nil
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
_ = agent.TerminatePID(rec.PID, rec.KillGroup)
|
|
370
|
+
if !waitGone(rec.PID, 2*time.Second) {
|
|
371
|
+
_ = agent.KillPID(rec.PID, rec.KillGroup)
|
|
372
|
+
if !waitGone(rec.PID, 2*time.Second) {
|
|
373
|
+
return StopResult{Record: rec, Reason: "已发信号但进程仍在(可能不属于当前用户)"},
|
|
374
|
+
fmt.Errorf("session %q (pid %d) 未能终止", id, rec.PID)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/* `--stop` = 调用方**主动取消** → cancelled(2026-09-29 对齐 anywhere 的 TimelineStatus;
|
|
378
|
+
老值 `stopped` 在读入时归一,见 normalizeState)。
|
|
379
|
+
⚠️ StopResult.Stopped 那个布尔**不改名**:它回答的是「这次停成功了吗」,
|
|
380
|
+
与状态词表无关,改了会连带 CLI 的 `-o json` 契约一起动。 */
|
|
381
|
+
rec.State = StateCancelled
|
|
382
|
+
rec.UpdatedAt = time.Now()
|
|
383
|
+
writeRecord(rec)
|
|
384
|
+
return StopResult{Record: rec, Stopped: true}, nil
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// waitGone 轮询等进程消失(每 50ms 一次)。
|
|
388
|
+
func waitGone(pid int, d time.Duration) bool {
|
|
389
|
+
deadline := time.Now().Add(d)
|
|
390
|
+
for {
|
|
391
|
+
if !agent.PIDAlive(pid) {
|
|
392
|
+
return true
|
|
393
|
+
}
|
|
394
|
+
if time.Now().After(deadline) {
|
|
395
|
+
return false
|
|
396
|
+
}
|
|
397
|
+
time.Sleep(50 * time.Millisecond)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// readRecord 读一条记录(坏文件当作不存在)。
|
|
402
|
+
func readRecord(path string) (Record, error) {
|
|
403
|
+
data, err := os.ReadFile(path)
|
|
404
|
+
if err != nil {
|
|
405
|
+
return Record{}, err
|
|
406
|
+
}
|
|
407
|
+
var rec Record
|
|
408
|
+
if err := json.Unmarshal(data, &rec); err != nil {
|
|
409
|
+
return Record{}, err
|
|
410
|
+
}
|
|
411
|
+
/* 旧记录里的 `stopped` 在这里翻成 `cancelled`(见 normalizeState)——
|
|
412
|
+
读的时候归一,就不必去批量改写磁盘上已有的记录。 */
|
|
413
|
+
rec.State = normalizeState(rec.State)
|
|
414
|
+
return rec, nil
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// writeRecord 覆盖写一条记录(按 RunID 定位文件名)。
|
|
418
|
+
func writeRecord(rec Record) {
|
|
419
|
+
dir := Dir()
|
|
420
|
+
if dir == "" || rec.RunID == "" {
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
data, err := json.MarshalIndent(rec, "", " ")
|
|
424
|
+
if err != nil {
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
_ = os.WriteFile(filepath.Join(dir, rec.RunID+".json"), data, 0o600)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// prune 删除过期记录(按 UpdatedAt;读不动的坏文件一并清掉)。
|
|
431
|
+
func prune(dir string) {
|
|
432
|
+
entries, err := os.ReadDir(dir)
|
|
433
|
+
if err != nil {
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
cutoff := time.Now().Add(-Retention)
|
|
437
|
+
for _, e := range entries {
|
|
438
|
+
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
p := filepath.Join(dir, e.Name())
|
|
442
|
+
rec, err := readRecord(p)
|
|
443
|
+
if err != nil || rec.UpdatedAt.Before(cutoff) {
|
|
444
|
+
_ = os.Remove(p)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
package session
|
|
2
|
+
|
|
3
|
+
// session_append.go - 常驻会话的「追加消息」通道(unix domain socket)。
|
|
4
|
+
//
|
|
5
|
+
// 用户需求(2026-09-18):「按路线 2 实现,先支持 claude 和 codebuddy 的追加功能」。
|
|
6
|
+
//
|
|
7
|
+
// 机制:claude/codebuddy 的 `--input-format stream-json` 允许在**同一个进程**里持续
|
|
8
|
+
// 接收 user 消息(= 会话中追加需求)。所以常驻会话(`--keep-alive`)会在自己的
|
|
9
|
+
// 会话记录里登记一个 unix socket 路径,任何进程都能:
|
|
10
|
+
//
|
|
11
|
+
// magic-agent --append <session_id> -p "追加内容"
|
|
12
|
+
//
|
|
13
|
+
// 由 `AppendMessage` 连上去把消息交给常驻进程,常驻进程再写进引擎的 stdin。
|
|
14
|
+
//
|
|
15
|
+
// 为什么用 unix socket 而不是 TCP/FIFO:
|
|
16
|
+
// - 权限天然受控:socket 文件 0600 + 目录 0700 → 只有同用户能追加,不需要额外 token;
|
|
17
|
+
// - FIFO 的「最后一个写者关闭 → 读端 EOF」语义会把引擎的 stdin 关掉,不能用;
|
|
18
|
+
// - TCP 需要暴露端口 + token 校验,在单机场景纯属多余。
|
|
19
|
+
//
|
|
20
|
+
// Windows 没有 unix socket → `ListenAppend` 明确报错(`--keep-alive` 会被挡下并说明原因),
|
|
21
|
+
// 不做静默降级。
|
|
22
|
+
|
|
23
|
+
import (
|
|
24
|
+
"bufio"
|
|
25
|
+
"encoding/json"
|
|
26
|
+
"fmt"
|
|
27
|
+
"io"
|
|
28
|
+
"net"
|
|
29
|
+
"os"
|
|
30
|
+
"path/filepath"
|
|
31
|
+
"runtime"
|
|
32
|
+
"strings"
|
|
33
|
+
"time"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// AppendProtocolVersion 追加协议版本(客户端/服务端握手用,便于以后演进)。
|
|
37
|
+
const AppendProtocolVersion = 1
|
|
38
|
+
|
|
39
|
+
// maxUnixSocketPath unix socket 路径的保守上限:macOS 的 sun_path 是 104 字节
|
|
40
|
+
// (Linux 108),留点余量取 100。
|
|
41
|
+
const maxUnixSocketPath = 100
|
|
42
|
+
|
|
43
|
+
// appendRequest 客户端 → 常驻进程 的一条消息。
|
|
44
|
+
type appendRequest struct {
|
|
45
|
+
Version int `json:"version"`
|
|
46
|
+
Text string `json:"text"`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// appendResponse 常驻进程 → 客户端的应答。
|
|
50
|
+
type appendResponse struct {
|
|
51
|
+
OK bool `json:"ok"`
|
|
52
|
+
Error string `json:"error,omitempty"`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Appender 常驻会话的追加入口(一个 unix socket)。
|
|
56
|
+
type Appender struct {
|
|
57
|
+
ln net.Listener
|
|
58
|
+
path string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Path 返回 socket 路径(写进会话记录,客户端据此连接)。
|
|
62
|
+
func (a *Appender) Path() string { return a.path }
|
|
63
|
+
|
|
64
|
+
// Serve 把收到的消息推给 ch(阻塞直到 ln 关闭或 ch 关闭)。
|
|
65
|
+
//
|
|
66
|
+
// 不关闭 ch —— 通道的生命周期归调用方(CLI):空闲超时/收到停止信号时由它关闭,
|
|
67
|
+
// 从而让引擎 stdin EOF、常驻会话优雅收尾。
|
|
68
|
+
func (a *Appender) Serve(ch chan<- string) {
|
|
69
|
+
for {
|
|
70
|
+
conn, err := a.ln.Accept()
|
|
71
|
+
if err != nil {
|
|
72
|
+
return // ln 已关闭
|
|
73
|
+
}
|
|
74
|
+
go a.handle(conn, ch)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// handle 处理一条连接:读一行 JSON,推入通道,回一行应答。
|
|
79
|
+
func (a *Appender) handle(conn net.Conn, ch chan<- string) {
|
|
80
|
+
defer conn.Close()
|
|
81
|
+
_ = conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
|
82
|
+
|
|
83
|
+
line, err := bufio.NewReader(conn).ReadString('\n')
|
|
84
|
+
if err != nil && strings.TrimSpace(line) == "" {
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
var req appendRequest
|
|
88
|
+
if err := json.Unmarshal([]byte(strings.TrimSpace(line)), &req); err != nil {
|
|
89
|
+
writeAppendResponse(conn, appendResponse{Error: "请求不是合法 JSON: " + err.Error()})
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if strings.TrimSpace(req.Text) == "" {
|
|
93
|
+
writeAppendResponse(conn, appendResponse{Error: "追加内容为空"})
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
select {
|
|
97
|
+
case ch <- req.Text:
|
|
98
|
+
writeAppendResponse(conn, appendResponse{OK: true})
|
|
99
|
+
case <-time.After(5 * time.Second):
|
|
100
|
+
// 通道满(追加太快):明确告知,不假装成功
|
|
101
|
+
writeAppendResponse(conn, appendResponse{Error: "追加队列已满,稍后再试"})
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// writeAppendResponse 回一行 JSON 应答(尽力而为,写不出去就算了)。
|
|
106
|
+
func writeAppendResponse(w io.Writer, resp appendResponse) {
|
|
107
|
+
data, err := json.Marshal(resp)
|
|
108
|
+
if err != nil {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
_, _ = w.Write(append(data, '\n'))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Close 关掉监听并删掉 socket 文件(幂等)。
|
|
115
|
+
func (a *Appender) Close() {
|
|
116
|
+
if a == nil {
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if a.ln != nil {
|
|
120
|
+
_ = a.ln.Close()
|
|
121
|
+
}
|
|
122
|
+
if a.path != "" {
|
|
123
|
+
_ = os.Remove(a.path)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ListenAppend 为一条会话开追加入口,并把路径写进会话记录(客户端据此找到它)。
|
|
128
|
+
//
|
|
129
|
+
// 目录/文件权限:sessions 目录 0700、socket 文件 0600 → 仅同用户可连。
|
|
130
|
+
func ListenAppend(h *Handle) (*Appender, error) {
|
|
131
|
+
if h == nil {
|
|
132
|
+
return nil, fmt.Errorf("no session handle")
|
|
133
|
+
}
|
|
134
|
+
if runtime.GOOS == "windows" {
|
|
135
|
+
return nil, fmt.Errorf("常驻会话(--keep-alive)与 --append 目前只支持 macOS/Linux:Windows 没有 unix domain socket")
|
|
136
|
+
}
|
|
137
|
+
dir := Dir()
|
|
138
|
+
if dir == "" {
|
|
139
|
+
return nil, fmt.Errorf("cannot resolve sessions dir")
|
|
140
|
+
}
|
|
141
|
+
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
142
|
+
return nil, err
|
|
143
|
+
}
|
|
144
|
+
path := filepath.Join(dir, h.RunID()+".sock")
|
|
145
|
+
// unix socket 路径有硬上限(macOS sun_path 104 / Linux 108 字节),超了 bind 会报
|
|
146
|
+
// 一句看不出原因的 "invalid argument"。提前拦住并给出可操作建议。
|
|
147
|
+
if len(path) > maxUnixSocketPath {
|
|
148
|
+
return nil, fmt.Errorf("追加入口路径过长(%d 字节,上限约 %d):%s;"+
|
|
149
|
+
"请把登记表目录换到短路径,例如 MAGIC_AGENT_SESSIONS=/tmp/magic-agent-sessions",
|
|
150
|
+
len(path), maxUnixSocketPath, path)
|
|
151
|
+
}
|
|
152
|
+
_ = os.Remove(path) // 清掉可能残留的同名 socket
|
|
153
|
+
ln, err := net.Listen("unix", path)
|
|
154
|
+
if err != nil {
|
|
155
|
+
return nil, fmt.Errorf("listen %s: %w", path, err)
|
|
156
|
+
}
|
|
157
|
+
if err := os.Chmod(path, 0o600); err != nil {
|
|
158
|
+
_ = ln.Close()
|
|
159
|
+
return nil, fmt.Errorf("chmod %s: %w", path, err)
|
|
160
|
+
}
|
|
161
|
+
h.SetAppendPath(path)
|
|
162
|
+
return &Appender{ln: ln, path: path}, nil
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// AppendResult 一次追加的结果。
|
|
166
|
+
type AppendResult struct {
|
|
167
|
+
Record Record `json:"record"`
|
|
168
|
+
Queued bool `json:"queued"`
|
|
169
|
+
Reason string `json:"reason,omitempty"`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// AppendMessage 把一条消息追加进指定会话(session_id 或 run_id 都收)。
|
|
173
|
+
//
|
|
174
|
+
// 失败分两种,调用方按退出码区分:
|
|
175
|
+
// - 找不到 id / 记录里没有追加入口 → 返回 error(exit 1,附可操作提示)
|
|
176
|
+
// - 常驻进程已经退出(连接被拒)→ 返回 error(exit 1,提示会话已结束)
|
|
177
|
+
func AppendMessage(id, text string) (AppendResult, error) {
|
|
178
|
+
msg := strings.TrimSpace(text)
|
|
179
|
+
if msg == "" {
|
|
180
|
+
return AppendResult{}, fmt.Errorf("追加内容为空")
|
|
181
|
+
}
|
|
182
|
+
rec, found, err := Find(id)
|
|
183
|
+
if err != nil {
|
|
184
|
+
return AppendResult{}, err
|
|
185
|
+
}
|
|
186
|
+
if !found {
|
|
187
|
+
return AppendResult{}, fmt.Errorf("no such session %q(--sessions 可列出全部)", id)
|
|
188
|
+
}
|
|
189
|
+
if strings.TrimSpace(rec.Append) == "" {
|
|
190
|
+
reason := "该会话没有追加入口(未用 --keep-alive 启动)"
|
|
191
|
+
if rec.State != StateRunning {
|
|
192
|
+
reason = "会话已结束(state=" + rec.State + ")"
|
|
193
|
+
}
|
|
194
|
+
return AppendResult{Record: rec, Reason: reason},
|
|
195
|
+
fmt.Errorf("%s;常驻会话要用 --stream --keep-alive 启动,已结束的会话请用 --session <id> 续接", reason)
|
|
196
|
+
}
|
|
197
|
+
if rec.State != StateRunning {
|
|
198
|
+
return AppendResult{Record: rec, Reason: "会话已结束(state=" + rec.State + ")"},
|
|
199
|
+
fmt.Errorf("会话已结束(state=%s);请用 --session <id> 续接", rec.State)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
conn, derr := net.DialTimeout("unix", rec.Append, 5*time.Second)
|
|
203
|
+
if derr != nil {
|
|
204
|
+
// 记录说在跑、socket 却连不上:常驻进程多半刚退出(或崩了)
|
|
205
|
+
rec.State = StateGone
|
|
206
|
+
rec.UpdatedAt = time.Now()
|
|
207
|
+
writeRecord(rec)
|
|
208
|
+
return AppendResult{Record: rec, Reason: "常驻进程已不在(连接失败)"},
|
|
209
|
+
fmt.Errorf("连不上会话 %s 的追加入口(%s):常驻进程可能已退出;请用 --sessions 复核", id, rec.Append)
|
|
210
|
+
}
|
|
211
|
+
defer conn.Close()
|
|
212
|
+
_ = conn.SetDeadline(time.Now().Add(15 * time.Second))
|
|
213
|
+
|
|
214
|
+
req, _ := json.Marshal(appendRequest{Version: AppendProtocolVersion, Text: msg})
|
|
215
|
+
if _, werr := conn.Write(append(req, '\n')); werr != nil {
|
|
216
|
+
return AppendResult{Record: rec, Reason: "写入失败"}, fmt.Errorf("写入追加入口失败: %w", werr)
|
|
217
|
+
}
|
|
218
|
+
line, rerr := bufio.NewReader(conn).ReadString('\n')
|
|
219
|
+
if rerr != nil && strings.TrimSpace(line) == "" {
|
|
220
|
+
return AppendResult{Record: rec, Reason: "没收到应答"}, fmt.Errorf("追加入口没回应: %w", rerr)
|
|
221
|
+
}
|
|
222
|
+
var resp appendResponse
|
|
223
|
+
if jerr := json.Unmarshal([]byte(strings.TrimSpace(line)), &resp); jerr != nil {
|
|
224
|
+
return AppendResult{Record: rec, Reason: "应答不是 JSON"}, fmt.Errorf("追加入口应答异常: %s", strings.TrimSpace(line))
|
|
225
|
+
}
|
|
226
|
+
if !resp.OK {
|
|
227
|
+
return AppendResult{Record: rec, Reason: resp.Error}, fmt.Errorf("追加被拒: %s", resp.Error)
|
|
228
|
+
}
|
|
229
|
+
rec.UpdatedAt = time.Now()
|
|
230
|
+
writeRecord(rec)
|
|
231
|
+
return AppendResult{Record: rec, Queued: true}, nil
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// SetAppendPath 把追加入口路径写进记录(由 ListenAppend 调用)。
|
|
235
|
+
func (h *Handle) SetAppendPath(path string) {
|
|
236
|
+
if h == nil {
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
h.rec.Append = path
|
|
240
|
+
h.write()
|
|
241
|
+
}
|