@co0ontty/wand 1.18.12 → 1.21.4
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/dist/claude-pty-bridge.d.ts +8 -0
- package/dist/claude-pty-bridge.js +34 -11
- package/dist/cli.js +72 -5
- package/dist/ensure-node-pty-helper.d.ts +1 -0
- package/dist/ensure-node-pty-helper.js +51 -0
- package/dist/git-quick-commit.d.ts +18 -0
- package/dist/git-quick-commit.js +381 -0
- package/dist/models.d.ts +3 -1
- package/dist/models.js +45 -7
- package/dist/process-manager.d.ts +6 -8
- package/dist/process-manager.js +90 -176
- package/dist/prompt-optimizer.d.ts +5 -0
- package/dist/prompt-optimizer.js +72 -0
- package/dist/pty-text-utils.d.ts +25 -1
- package/dist/pty-text-utils.js +158 -2
- package/dist/server-session-routes.d.ts +2 -2
- package/dist/server-session-routes.js +94 -8
- package/dist/server.d.ts +22 -1
- package/dist/server.js +138 -16
- package/dist/session-logger.d.ts +15 -4
- package/dist/session-logger.js +52 -4
- package/dist/structured-session-manager.d.ts +12 -2
- package/dist/structured-session-manager.js +465 -22
- package/dist/tui/index.d.ts +24 -0
- package/dist/tui/index.js +138 -0
- package/dist/tui/layout.d.ts +25 -0
- package/dist/tui/layout.js +198 -0
- package/dist/tui/log-bus.d.ts +23 -0
- package/dist/tui/log-bus.js +111 -0
- package/dist/tui/relative-time.d.ts +4 -0
- package/dist/tui/relative-time.js +27 -0
- package/dist/tui/session-formatter.d.ts +17 -0
- package/dist/tui/session-formatter.js +111 -0
- package/dist/types.d.ts +55 -2
- package/dist/web-ui/content/scripts.js +1371 -261
- package/dist/web-ui/content/styles.css +436 -9
- package/dist/web-ui/content/vendor/wterm/wterm.bundle.js +1 -1
- package/dist/ws-broadcast.js +74 -12
- package/package.json +3 -1
package/dist/models.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { exec } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
const execAsync = promisify(exec);
|
|
4
|
-
const
|
|
4
|
+
const CLAUDE_MODELS = [
|
|
5
5
|
{ id: "default", label: "default(跟随 Claude Code 默认)", alias: true },
|
|
6
6
|
{ id: "opus", label: "opus(最新 Opus)", alias: true },
|
|
7
7
|
{ id: "sonnet", label: "sonnet(最新 Sonnet)", alias: true },
|
|
@@ -11,9 +11,12 @@ const BUILT_IN_MODELS = [
|
|
|
11
11
|
{ id: "claude-sonnet-4-6", label: "Sonnet 4.6 · claude-sonnet-4-6" },
|
|
12
12
|
{ id: "claude-haiku-4-5-20251001", label: "Haiku 4.5 · claude-haiku-4-5-20251001" },
|
|
13
13
|
];
|
|
14
|
+
const CODEX_FALLBACK_MODELS = [
|
|
15
|
+
{ id: "default", label: "default(跟随 Codex 默认)", alias: true },
|
|
16
|
+
];
|
|
14
17
|
let cache = null;
|
|
15
|
-
function
|
|
16
|
-
return
|
|
18
|
+
function cloneClaudeModels() {
|
|
19
|
+
return CLAUDE_MODELS.map((m) => ({ ...m }));
|
|
17
20
|
}
|
|
18
21
|
async function probeClaudeVersion() {
|
|
19
22
|
try {
|
|
@@ -25,10 +28,37 @@ async function probeClaudeVersion() {
|
|
|
25
28
|
return null;
|
|
26
29
|
}
|
|
27
30
|
}
|
|
31
|
+
async function probeCodexModels() {
|
|
32
|
+
try {
|
|
33
|
+
const { stdout } = await execAsync("codex debug models", { timeout: 8000 });
|
|
34
|
+
const data = JSON.parse(stdout);
|
|
35
|
+
const visible = data.models
|
|
36
|
+
.filter((m) => m.visibility === "list")
|
|
37
|
+
.sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99));
|
|
38
|
+
if (!visible.length)
|
|
39
|
+
return CODEX_FALLBACK_MODELS.map((m) => ({ ...m }));
|
|
40
|
+
const result = [
|
|
41
|
+
{ id: "default", label: "default(跟随 Codex 默认)", alias: true },
|
|
42
|
+
];
|
|
43
|
+
for (const m of visible) {
|
|
44
|
+
result.push({
|
|
45
|
+
id: m.slug,
|
|
46
|
+
label: m.display_name && m.display_name !== m.slug
|
|
47
|
+
? `${m.display_name} · ${m.slug}`
|
|
48
|
+
: m.slug,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return CODEX_FALLBACK_MODELS.map((m) => ({ ...m }));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
28
57
|
export function getCachedModels() {
|
|
29
58
|
if (!cache) {
|
|
30
59
|
cache = {
|
|
31
|
-
models:
|
|
60
|
+
models: cloneClaudeModels(),
|
|
61
|
+
codexModels: CODEX_FALLBACK_MODELS.map((m) => ({ ...m })),
|
|
32
62
|
claudeVersion: null,
|
|
33
63
|
refreshedAt: new Date().toISOString(),
|
|
34
64
|
};
|
|
@@ -36,17 +66,25 @@ export function getCachedModels() {
|
|
|
36
66
|
return cache;
|
|
37
67
|
}
|
|
38
68
|
export async function refreshModels() {
|
|
39
|
-
const version = await
|
|
69
|
+
const [version, codexModels] = await Promise.all([
|
|
70
|
+
probeClaudeVersion(),
|
|
71
|
+
probeCodexModels(),
|
|
72
|
+
]);
|
|
40
73
|
cache = {
|
|
41
|
-
models:
|
|
74
|
+
models: cloneClaudeModels(),
|
|
75
|
+
codexModels,
|
|
42
76
|
claudeVersion: version,
|
|
43
77
|
refreshedAt: new Date().toISOString(),
|
|
44
78
|
};
|
|
45
79
|
return cache;
|
|
46
80
|
}
|
|
81
|
+
export function getModelsForProvider(provider) {
|
|
82
|
+
const cached = getCachedModels();
|
|
83
|
+
return provider === "codex" ? cached.codexModels : cached.models;
|
|
84
|
+
}
|
|
47
85
|
/** 返回可用于 claude CLI 的全部已知 model id(含别名) */
|
|
48
86
|
export function knownModelIds() {
|
|
49
|
-
return
|
|
87
|
+
return CLAUDE_MODELS.map((m) => m.id);
|
|
50
88
|
}
|
|
51
89
|
/** 判断传入值是否是已知模型;允许自由文本,因此总是返回 true。保留接口以便将来严格校验。 */
|
|
52
90
|
export function isKnownModel(_value) {
|
|
@@ -35,8 +35,12 @@ export declare class ProcessManager extends EventEmitter {
|
|
|
35
35
|
private readonly persistDebounceTimers;
|
|
36
36
|
/** Last persisted message state per session — used to skip redundant message writes */
|
|
37
37
|
private readonly lastPersistedMessageState;
|
|
38
|
+
/** 启动时被识别为孤儿 PTY 并标记为 exited 的旧会话数(旧服务器进程已死) */
|
|
39
|
+
private orphanRecoveredCount;
|
|
38
40
|
constructor(config: WandConfig, storage: WandStorage, configDir?: string);
|
|
39
41
|
on(event: "process", listener: ProcessEventHandler): this;
|
|
42
|
+
/** 启动时被识别为孤儿 PTY 并标记为 exited 的旧会话数量(仅用于启动摘要展示)。 */
|
|
43
|
+
getOrphanRecoveredCount(): number;
|
|
40
44
|
private emitEvent;
|
|
41
45
|
private cleanupOldSessions;
|
|
42
46
|
start(command: string, cwd: string | undefined, mode: ExecutionMode, initialInput?: string, opts?: {
|
|
@@ -46,6 +50,8 @@ export declare class ProcessManager extends EventEmitter {
|
|
|
46
50
|
provider?: SessionProvider;
|
|
47
51
|
model?: string;
|
|
48
52
|
reuseId?: string;
|
|
53
|
+
cols?: number;
|
|
54
|
+
rows?: number;
|
|
49
55
|
}): SessionSnapshot;
|
|
50
56
|
list(): SessionSnapshot[];
|
|
51
57
|
/** Return lightweight snapshots for the session list (no output/messages). */
|
|
@@ -103,14 +109,6 @@ export declare class ProcessManager extends EventEmitter {
|
|
|
103
109
|
* Use this at critical points (exit, stop, delete) to ensure no data loss.
|
|
104
110
|
*/
|
|
105
111
|
private flushPersist;
|
|
106
|
-
private backfillExitedClaudeSessionIds;
|
|
107
|
-
/**
|
|
108
|
-
* Auto-recover the most recent exited session that has a Claude session ID.
|
|
109
|
-
* Only resumes one session per server start, using the most recent eligible
|
|
110
|
-
* session. Reuses the original session ID (in-place resume) and sets
|
|
111
|
-
* `autoRecovered: true`.
|
|
112
|
-
*/
|
|
113
|
-
private autoRecoverExitedSessions;
|
|
114
112
|
private archiveExpiredSessions;
|
|
115
113
|
private assertCommandAllowed;
|
|
116
114
|
/**
|
package/dist/process-manager.js
CHANGED
|
@@ -10,7 +10,7 @@ import { ClaudePtyBridge } from "./claude-pty-bridge.js";
|
|
|
10
10
|
import { truncateMessagesForTransport } from "./message-truncator.js";
|
|
11
11
|
import { appendWindow, hasExplicitConfirmSyntax, hasPermissionActionContext, normalizePromptText } from "./pty-text-utils.js";
|
|
12
12
|
import { prepareSessionWorktree } from "./git-worktree.js";
|
|
13
|
-
import { getResumeCommandSessionId
|
|
13
|
+
import { getResumeCommandSessionId } from "./resume-policy.js";
|
|
14
14
|
function resolveProviderFromCommand(command) {
|
|
15
15
|
return /^codex\b/.test(command.trim()) ? "codex" : "claude";
|
|
16
16
|
}
|
|
@@ -108,6 +108,10 @@ function hasRecentProjectActivity(candidate, startedAt) {
|
|
|
108
108
|
}
|
|
109
109
|
function selectClaudeProjectSessionForRecord(record) {
|
|
110
110
|
const knownMtimes = record.knownClaudeProjectMtimes ?? new Map();
|
|
111
|
+
// Only consider files created/touched AFTER this wand session started — those
|
|
112
|
+
// are the ones a fresh `claude` invocation could have produced. Files that
|
|
113
|
+
// existed before (knownMtimes entry present) are tolerated only if they
|
|
114
|
+
// grew since we observed them, but we de-prioritize them below.
|
|
111
115
|
const candidates = listClaudeProjectSessionCandidates(record.cwd)
|
|
112
116
|
.filter((candidate) => {
|
|
113
117
|
const previousMtime = knownMtimes.get(candidate.id);
|
|
@@ -125,54 +129,22 @@ function selectClaudeProjectSessionForRecord(record) {
|
|
|
125
129
|
if (!hasUserTurn) {
|
|
126
130
|
return null;
|
|
127
131
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
function selectClaudeProjectSessionByProximity(record) {
|
|
138
|
-
const hasUserTurn = record.messages.some((turn) => turn.role === "user"
|
|
139
|
-
&& turn.content.some((block) => block.type === "text" && block.text.trim().length > 0));
|
|
140
|
-
if (!hasUserTurn) {
|
|
132
|
+
// Prefer brand-new files (id not in knownMtimes at session start). When more
|
|
133
|
+
// than one fresh candidate exists in parallel sessions, refuse to bind —
|
|
134
|
+
// mis-binding another session's history is worse than waiting for the bridge
|
|
135
|
+
// to capture the canonical session id from PTY output.
|
|
136
|
+
const fresh = candidates.filter((candidate) => !knownMtimes.has(candidate.id));
|
|
137
|
+
if (fresh.length === 1) {
|
|
138
|
+
return fresh[0];
|
|
139
|
+
}
|
|
140
|
+
if (fresh.length > 1) {
|
|
141
141
|
return null;
|
|
142
142
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
// Look for files modified from ~60s before session start up to now
|
|
146
|
-
const proximityWindowMs = 60 * 1000;
|
|
147
|
-
const candidates = listClaudeProjectSessionCandidates(record.cwd)
|
|
148
|
-
.filter((candidate) => {
|
|
149
|
-
if (!Number.isFinite(startedAtMs))
|
|
150
|
-
return true;
|
|
151
|
-
return candidate.mtimeMs >= startedAtMs - proximityWindowMs
|
|
152
|
-
&& candidate.mtimeMs <= now + DISCOVERY_RECENT_WINDOW_MS;
|
|
153
|
-
})
|
|
154
|
-
.map((candidate) => readClaudeProjectSessionDetails(candidate.filePath, candidate.id))
|
|
155
|
-
.filter((candidate) => Boolean(candidate?.hasConversation))
|
|
156
|
-
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
157
|
-
return candidates[0] ?? null;
|
|
158
|
-
}
|
|
159
|
-
function getResumeEligibility(record) {
|
|
160
|
-
const hasClaudeSessionId = Boolean(record.claudeSessionId);
|
|
161
|
-
const hasRealConversation = hasRealConversationMessages(record.messages);
|
|
162
|
-
return {
|
|
163
|
-
hasClaudeSessionId,
|
|
164
|
-
hasRealConversation,
|
|
165
|
-
eligible: hasClaudeSessionId && hasRealConversation
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
function hasResumeEligibleConversation(record) {
|
|
169
|
-
return getResumeEligibility(record).eligible;
|
|
143
|
+
// Fallback: existing file that grew. Only bind if a single grown candidate.
|
|
144
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
170
145
|
}
|
|
171
146
|
function getLatestClaudeProjectSessionId(record) {
|
|
172
|
-
|
|
173
|
-
return selectClaudeProjectSessionForRecord(record)?.id
|
|
174
|
-
?? selectClaudeProjectSessionByProximity(record)?.id
|
|
175
|
-
?? null;
|
|
147
|
+
return selectClaudeProjectSessionForRecord(record)?.id ?? null;
|
|
176
148
|
}
|
|
177
149
|
function listRecentClaudeProjectSessionIds(cwd, startedAt) {
|
|
178
150
|
return listClaudeProjectSessionCandidates(cwd)
|
|
@@ -180,33 +152,6 @@ function listRecentClaudeProjectSessionIds(cwd, startedAt) {
|
|
|
180
152
|
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
181
153
|
.map((candidate) => candidate.id);
|
|
182
154
|
}
|
|
183
|
-
function findRealClaudeProjectSessionId(cwd, startedAt) {
|
|
184
|
-
// Strict mtime-based discovery first
|
|
185
|
-
const candidates = listRecentClaudeProjectSessionIds(cwd, startedAt)
|
|
186
|
-
.map((id) => {
|
|
187
|
-
const filePath = path.join(getClaudeProjectDir(cwd), `${id}.jsonl`);
|
|
188
|
-
return readClaudeProjectSessionDetails(filePath, id);
|
|
189
|
-
})
|
|
190
|
-
.filter((candidate) => Boolean(candidate?.hasConversation))
|
|
191
|
-
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
192
|
-
if (candidates.length > 0)
|
|
193
|
-
return candidates[0].id;
|
|
194
|
-
// Fallback: broader proximity search for files with conversation content
|
|
195
|
-
const startedAtMs = Date.parse(startedAt);
|
|
196
|
-
const now = Date.now();
|
|
197
|
-
const proximityWindowMs = 60 * 1000;
|
|
198
|
-
const proximityCandidates = listClaudeProjectSessionCandidates(cwd)
|
|
199
|
-
.filter((candidate) => {
|
|
200
|
-
if (!Number.isFinite(startedAtMs))
|
|
201
|
-
return true;
|
|
202
|
-
return candidate.mtimeMs >= startedAtMs - proximityWindowMs
|
|
203
|
-
&& candidate.mtimeMs <= now + DISCOVERY_RECENT_WINDOW_MS;
|
|
204
|
-
})
|
|
205
|
-
.map((candidate) => readClaudeProjectSessionDetails(candidate.filePath, candidate.id))
|
|
206
|
-
.filter((candidate) => Boolean(candidate?.hasConversation))
|
|
207
|
-
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
208
|
-
return proximityCandidates[0]?.id ?? null;
|
|
209
|
-
}
|
|
210
155
|
function isClaudeSessionFileAvailable(cwd, claudeSessionId) {
|
|
211
156
|
const filePath = path.join(getClaudeProjectDir(cwd), `${claudeSessionId}.jsonl`);
|
|
212
157
|
return Boolean(readClaudeProjectSessionDetails(filePath, claudeSessionId));
|
|
@@ -348,18 +293,6 @@ function listAllClaudeHistorySessions() {
|
|
|
348
293
|
return [];
|
|
349
294
|
}
|
|
350
295
|
}
|
|
351
|
-
function shouldAutoResumeSession(record) {
|
|
352
|
-
return record.status === "exited"
|
|
353
|
-
&& !record.archived
|
|
354
|
-
&& record.ptyProcess === null
|
|
355
|
-
&& hasResumeEligibleConversation(record);
|
|
356
|
-
}
|
|
357
|
-
function shouldBackfillClaudeSessionId(record) {
|
|
358
|
-
return record.status === "exited"
|
|
359
|
-
&& !record.claudeSessionId
|
|
360
|
-
&& /^claude\b/.test(record.command.trim())
|
|
361
|
-
&& hasRealConversationMessages(record.messages);
|
|
362
|
-
}
|
|
363
296
|
function snapshotMessages(record) {
|
|
364
297
|
return record.ptyBridge?.getMessages() ?? record.messages;
|
|
365
298
|
}
|
|
@@ -455,6 +388,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
455
388
|
persistDebounceTimers = new Map();
|
|
456
389
|
/** Last persisted message state per session — used to skip redundant message writes */
|
|
457
390
|
lastPersistedMessageState = new Map();
|
|
391
|
+
/** 启动时被识别为孤儿 PTY 并标记为 exited 的旧会话数(旧服务器进程已死) */
|
|
392
|
+
orphanRecoveredCount = 0;
|
|
458
393
|
constructor(config, storage, configDir) {
|
|
459
394
|
super();
|
|
460
395
|
this.config = config;
|
|
@@ -488,7 +423,10 @@ export class ProcessManager extends EventEmitter {
|
|
|
488
423
|
confirmWindow: "",
|
|
489
424
|
ptyPermissionBlocked: false,
|
|
490
425
|
lastAutoConfirmAt: 0,
|
|
491
|
-
|
|
426
|
+
// Preserve a user-toggled auto-approve setting across server restarts
|
|
427
|
+
// instead of recomputing it from the command/mode pair.
|
|
428
|
+
autoApprovePermissions: snapshot.autoApprovePermissions
|
|
429
|
+
?? this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
|
|
492
430
|
pendingEscalation: snapshot.pendingEscalation ?? null,
|
|
493
431
|
lastEscalationResult: snapshot.lastEscalationResult ?? null,
|
|
494
432
|
autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
|
|
@@ -507,9 +445,11 @@ export class ProcessManager extends EventEmitter {
|
|
|
507
445
|
claudeTaskDiscoveryTimer: null,
|
|
508
446
|
knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(updated.cwd) : undefined,
|
|
509
447
|
claudeSessionId: resumeCommandSessionId ?? updated.claudeSessionId,
|
|
510
|
-
approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
|
|
448
|
+
approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
|
|
449
|
+
ptyCols: snapshot.ptyCols ?? 120,
|
|
450
|
+
ptyRows: snapshot.ptyRows ?? 36,
|
|
511
451
|
});
|
|
512
|
-
|
|
452
|
+
this.orphanRecoveredCount += 1;
|
|
513
453
|
}
|
|
514
454
|
else {
|
|
515
455
|
this.sessions.set(snapshot.id, {
|
|
@@ -521,7 +461,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
521
461
|
confirmWindow: "",
|
|
522
462
|
ptyPermissionBlocked: false,
|
|
523
463
|
lastAutoConfirmAt: 0,
|
|
524
|
-
autoApprovePermissions:
|
|
464
|
+
autoApprovePermissions: snapshot.autoApprovePermissions
|
|
465
|
+
?? this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
|
|
525
466
|
pendingEscalation: snapshot.pendingEscalation ?? null,
|
|
526
467
|
lastEscalationResult: snapshot.lastEscalationResult ?? null,
|
|
527
468
|
autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
|
|
@@ -540,16 +481,12 @@ export class ProcessManager extends EventEmitter {
|
|
|
540
481
|
claudeTaskDiscoveryTimer: null,
|
|
541
482
|
knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(snapshot.cwd) : undefined,
|
|
542
483
|
claudeSessionId: resumeCommandSessionId ?? snapshot.claudeSessionId,
|
|
543
|
-
approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
|
|
484
|
+
approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
|
|
485
|
+
ptyCols: snapshot.ptyCols ?? 120,
|
|
486
|
+
ptyRows: snapshot.ptyRows ?? 36,
|
|
544
487
|
});
|
|
545
488
|
}
|
|
546
489
|
}
|
|
547
|
-
// Defer expensive file-system scanning and auto-recovery so the server
|
|
548
|
-
// can start responding to requests immediately.
|
|
549
|
-
setImmediate(() => {
|
|
550
|
-
this.backfillExitedClaudeSessionIds();
|
|
551
|
-
this.autoRecoverExitedSessions();
|
|
552
|
-
});
|
|
553
490
|
this.archiveExpiredSessions();
|
|
554
491
|
this.archiveTimer = setInterval(() => {
|
|
555
492
|
try {
|
|
@@ -564,6 +501,10 @@ export class ProcessManager extends EventEmitter {
|
|
|
564
501
|
on(event, listener) {
|
|
565
502
|
return super.on("process", listener);
|
|
566
503
|
}
|
|
504
|
+
/** 启动时被识别为孤儿 PTY 并标记为 exited 的旧会话数量(仅用于启动摘要展示)。 */
|
|
505
|
+
getOrphanRecoveredCount() {
|
|
506
|
+
return this.orphanRecoveredCount;
|
|
507
|
+
}
|
|
567
508
|
emitEvent(event) {
|
|
568
509
|
this.emit("process", event);
|
|
569
510
|
}
|
|
@@ -614,12 +555,22 @@ export class ProcessManager extends EventEmitter {
|
|
|
614
555
|
? path.resolve(process.cwd(), cwd)
|
|
615
556
|
: path.resolve(process.cwd(), this.config.defaultCwd);
|
|
616
557
|
const id = opts?.reuseId || randomUUID();
|
|
558
|
+
// When a session is being resumed under the same id, capture its prior
|
|
559
|
+
// structured messages so the new bridge can present them as the chat
|
|
560
|
+
// history. We deliberately do NOT carry over rawOutput — `claude --resume`
|
|
561
|
+
// re-prints its own banner and replayed history into the new PTY, and
|
|
562
|
+
// mixing the two would surface every line twice in the terminal view.
|
|
563
|
+
let priorMessages = [];
|
|
617
564
|
if (opts?.reuseId) {
|
|
618
565
|
const oldRecord = this.sessions.get(id);
|
|
619
566
|
if (oldRecord) {
|
|
567
|
+
priorMessages = oldRecord.ptyBridge?.getMessages() ?? oldRecord.messages ?? [];
|
|
620
568
|
this.cleanupRecord(oldRecord);
|
|
621
569
|
this.sessions.delete(id);
|
|
622
570
|
}
|
|
571
|
+
else {
|
|
572
|
+
priorMessages = this.storage.getSession(id)?.messages ?? [];
|
|
573
|
+
}
|
|
623
574
|
}
|
|
624
575
|
const worktreeSetup = opts?.worktreeEnabled
|
|
625
576
|
? prepareSessionWorktree({ cwd: baseCwd, sessionId: id })
|
|
@@ -671,7 +622,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
671
622
|
rememberedEscalationScopes: new Set(),
|
|
672
623
|
rememberedEscalationTargets: new Set(),
|
|
673
624
|
storedOutput: "",
|
|
674
|
-
messages:
|
|
625
|
+
messages: priorMessages,
|
|
675
626
|
childProcess: null,
|
|
676
627
|
ptyBridge: null,
|
|
677
628
|
currentTask: null,
|
|
@@ -682,6 +633,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
682
633
|
knownClaudeProjectMtimes: knownClaudeProjectMtimes ?? undefined,
|
|
683
634
|
approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
|
|
684
635
|
selectedModel: selectedModel ?? null,
|
|
636
|
+
ptyCols: opts?.cols !== undefined ? clampDimension(opts.cols, 20, 400) : 120,
|
|
637
|
+
ptyRows: opts?.rows !== undefined ? clampDimension(opts.rows, 10, 160) : 36,
|
|
685
638
|
};
|
|
686
639
|
if (isClaudeProvider) {
|
|
687
640
|
record.ptyBridge = new ClaudePtyBridge({
|
|
@@ -689,6 +642,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
689
642
|
isClaudeCommand: true,
|
|
690
643
|
autoApprove: record.autoApprovePermissions,
|
|
691
644
|
approvalPolicy: record.approvalPolicy,
|
|
645
|
+
initialMessages: priorMessages,
|
|
692
646
|
});
|
|
693
647
|
record.ptyBridge.on("event", (event) => {
|
|
694
648
|
this.handleBridgeEvent(record, event);
|
|
@@ -712,8 +666,10 @@ export class ProcessManager extends EventEmitter {
|
|
|
712
666
|
WAND_AUTO_EDIT: effectiveMode === "auto-edit" ? "1" : "0"
|
|
713
667
|
},
|
|
714
668
|
name: "xterm-color",
|
|
715
|
-
|
|
716
|
-
|
|
669
|
+
// 使用 record 上由前端协商好的真实尺寸,避免"先 120 列、几百毫秒后再 resize"
|
|
670
|
+
// 期间 Claude/Codex 用错列宽渲染出 \x1b[120G 这类绝对列定位序列。
|
|
671
|
+
cols: record.ptyCols,
|
|
672
|
+
rows: record.ptyRows
|
|
717
673
|
});
|
|
718
674
|
}
|
|
719
675
|
catch (err) {
|
|
@@ -939,15 +895,13 @@ export class ProcessManager extends EventEmitter {
|
|
|
939
895
|
get(id) {
|
|
940
896
|
const record = this.sessions.get(id);
|
|
941
897
|
if (!record) {
|
|
942
|
-
// Fallback: check SQLite for sessions that were evicted from memory
|
|
943
898
|
return this.storage.getSession(id) ?? null;
|
|
944
899
|
}
|
|
945
|
-
|
|
946
|
-
// Prefer in-memory output (live PTY data), fall back to stored output.
|
|
900
|
+
const result = this.snapshot(record);
|
|
947
901
|
if (!record.output && record.storedOutput) {
|
|
948
|
-
|
|
902
|
+
result.output = record.storedOutput;
|
|
949
903
|
}
|
|
950
|
-
return
|
|
904
|
+
return result;
|
|
951
905
|
}
|
|
952
906
|
getPtyTranscript(id) {
|
|
953
907
|
return this.logger.readPtyOutput(id);
|
|
@@ -959,12 +913,9 @@ export class ProcessManager extends EventEmitter {
|
|
|
959
913
|
*/
|
|
960
914
|
setSessionModel(id, model) {
|
|
961
915
|
const record = this.mustGet(id);
|
|
962
|
-
if (record.provider !== "claude") {
|
|
963
|
-
throw new Error("仅 Claude 会话支持切换模型。");
|
|
964
|
-
}
|
|
965
916
|
const normalized = model?.trim() || null;
|
|
966
917
|
record.selectedModel = normalized;
|
|
967
|
-
if (record.status === "running" && record.ptyProcess) {
|
|
918
|
+
if (record.provider === "claude" && record.status === "running" && record.ptyProcess) {
|
|
968
919
|
const value = normalized && normalized !== "default" ? normalized : "default";
|
|
969
920
|
record.ptyProcess.write(`/model ${value}\r`);
|
|
970
921
|
}
|
|
@@ -1037,7 +988,20 @@ export class ProcessManager extends EventEmitter {
|
|
|
1037
988
|
}
|
|
1038
989
|
const safeCols = clampDimension(cols, 20, 400);
|
|
1039
990
|
const safeRows = clampDimension(rows, 10, 160);
|
|
991
|
+
const changed = safeCols !== record.ptyCols || safeRows !== record.ptyRows;
|
|
1040
992
|
record.ptyProcess.resize(safeCols, safeRows);
|
|
993
|
+
record.ptyCols = safeCols;
|
|
994
|
+
record.ptyRows = safeRows;
|
|
995
|
+
if (changed) {
|
|
996
|
+
// Notify every subscribed client of the new authoritative dimensions so
|
|
997
|
+
// any other tab/device can re-fit its terminal instead of rendering
|
|
998
|
+
// wrap-broken output sized for someone else's viewport.
|
|
999
|
+
this.emitEvent({
|
|
1000
|
+
type: "status",
|
|
1001
|
+
sessionId: id,
|
|
1002
|
+
data: { ptyCols: safeCols, ptyRows: safeRows },
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1041
1005
|
return this.snapshot(record);
|
|
1042
1006
|
}
|
|
1043
1007
|
stop(id) {
|
|
@@ -1236,6 +1200,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
1236
1200
|
summary: deriveSessionSummary(messages, record.currentTask?.title ?? null),
|
|
1237
1201
|
currentTaskTitle: record.status === "running" ? record.currentTask?.title ?? undefined : undefined,
|
|
1238
1202
|
selectedModel: record.selectedModel ?? null,
|
|
1203
|
+
ptyCols: record.ptyCols,
|
|
1204
|
+
ptyRows: record.ptyRows,
|
|
1239
1205
|
};
|
|
1240
1206
|
}
|
|
1241
1207
|
/** Lightweight snapshot for list views — omits output and messages. */
|
|
@@ -1374,70 +1340,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
1374
1340
|
}
|
|
1375
1341
|
this.persist(record);
|
|
1376
1342
|
}
|
|
1377
|
-
backfillExitedClaudeSessionIds() {
|
|
1378
|
-
for (const record of this.sessions.values()) {
|
|
1379
|
-
record.messages = snapshotMessages(record);
|
|
1380
|
-
if (!shouldBackfillClaudeSessionId(record)) {
|
|
1381
|
-
continue;
|
|
1382
|
-
}
|
|
1383
|
-
const discoveredSessionId = findRealClaudeProjectSessionId(record.cwd, record.startedAt);
|
|
1384
|
-
if (!discoveredSessionId) {
|
|
1385
|
-
continue;
|
|
1386
|
-
}
|
|
1387
|
-
record.claudeSessionId = discoveredSessionId;
|
|
1388
|
-
this.persist(record);
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
/**
|
|
1392
|
-
* Auto-recover the most recent exited session that has a Claude session ID.
|
|
1393
|
-
* Only resumes one session per server start, using the most recent eligible
|
|
1394
|
-
* session. Reuses the original session ID (in-place resume) and sets
|
|
1395
|
-
* `autoRecovered: true`.
|
|
1396
|
-
*/
|
|
1397
|
-
autoRecoverExitedSessions() {
|
|
1398
|
-
// Find eligible exited sessions
|
|
1399
|
-
const eligibleSessions = [];
|
|
1400
|
-
for (const record of this.sessions.values()) {
|
|
1401
|
-
record.messages = snapshotMessages(record);
|
|
1402
|
-
if (shouldAutoResumeSession(record)) {
|
|
1403
|
-
eligibleSessions.push(record);
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1406
|
-
if (eligibleSessions.length === 0)
|
|
1407
|
-
return;
|
|
1408
|
-
// Sort by startedAt descending (most recent first)
|
|
1409
|
-
eligibleSessions.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
1410
|
-
// Only auto-recover the single most recent session
|
|
1411
|
-
const original = eligibleSessions[0];
|
|
1412
|
-
const isClaude = /^claude\b/.test(original.command.trim());
|
|
1413
|
-
if (!isClaude)
|
|
1414
|
-
return;
|
|
1415
|
-
// If no claudeSessionId is bound yet, try to discover it via proximity search
|
|
1416
|
-
if (!original.claudeSessionId) {
|
|
1417
|
-
const discovered = findRealClaudeProjectSessionId(original.cwd, original.startedAt);
|
|
1418
|
-
if (discovered) {
|
|
1419
|
-
original.claudeSessionId = discovered;
|
|
1420
|
-
process.stderr.write(`[wand] Backfilled Claude session ID for auto-recovery: ${discovered}\n`);
|
|
1421
|
-
this.persist(original);
|
|
1422
|
-
}
|
|
1423
|
-
}
|
|
1424
|
-
if (!original.claudeSessionId) {
|
|
1425
|
-
console.error(`[ProcessManager] Skipping auto-recovery: no Claude session ID for session ${original.id}`);
|
|
1426
|
-
return;
|
|
1427
|
-
}
|
|
1428
|
-
console.error(`[ProcessManager] Auto-recovering session ${original.id} with Claude session ID ${original.claudeSessionId}`);
|
|
1429
|
-
const resumeCommand = `${original.command.trim()} --resume ${original.claudeSessionId}`;
|
|
1430
|
-
try {
|
|
1431
|
-
const snapshot = this.start(resumeCommand, original.cwd, original.mode, undefined, {
|
|
1432
|
-
reuseId: original.id,
|
|
1433
|
-
autoRecovered: true
|
|
1434
|
-
});
|
|
1435
|
-
console.error(`[ProcessManager] Auto-recovered session ${snapshot.id} (in-place)`);
|
|
1436
|
-
}
|
|
1437
|
-
catch (err) {
|
|
1438
|
-
console.error(`[ProcessManager] Auto-recovery failed: ${String(err)}`);
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
1343
|
archiveExpiredSessions() {
|
|
1442
1344
|
const now = Date.now();
|
|
1443
1345
|
for (const record of this.sessions.values()) {
|
|
@@ -1518,9 +1420,16 @@ export class ProcessManager extends EventEmitter {
|
|
|
1518
1420
|
record.output = record.ptyBridge?.getRawOutput() ?? record.output;
|
|
1519
1421
|
const rawMessages = record.ptyBridge?.getMessages() ?? [];
|
|
1520
1422
|
const isStreaming = record.status === "running";
|
|
1423
|
+
const bridgeData = event.data;
|
|
1521
1424
|
const data = {
|
|
1522
1425
|
permissionBlocked: this.isPermissionBlocked(record),
|
|
1523
1426
|
};
|
|
1427
|
+
// 透传 bridge 给出的 isResponding(true=流式中, false=本轮已完成)。
|
|
1428
|
+
// 前端用它检测 thinking→idle 边界并主动做一次终端 resync,把 Claude/Codex
|
|
1429
|
+
// 在流式渲染过程中残留的错位光标定位序列洗掉(等价于按一次右上角缩放)。
|
|
1430
|
+
if (bridgeData && typeof bridgeData.isResponding === "boolean") {
|
|
1431
|
+
data.isResponding = bridgeData.isResponding;
|
|
1432
|
+
}
|
|
1524
1433
|
if (isStreaming && rawMessages.length > 0) {
|
|
1525
1434
|
data.incremental = true;
|
|
1526
1435
|
const lastTurn = rawMessages[rawMessages.length - 1];
|
|
@@ -1672,13 +1581,18 @@ export class ProcessManager extends EventEmitter {
|
|
|
1672
1581
|
}
|
|
1673
1582
|
processCommandForMode(command, mode, provider, model) {
|
|
1674
1583
|
if (provider === "codex") {
|
|
1675
|
-
|
|
1676
|
-
|
|
1584
|
+
let result = command;
|
|
1585
|
+
const trimmedModel = model?.trim();
|
|
1586
|
+
if (trimmedModel && trimmedModel !== "default" && !/--model\s/.test(command) && !/-m\s/.test(command)) {
|
|
1587
|
+
const escapedModel = trimmedModel.replace(/'/g, "'\\''");
|
|
1588
|
+
result += ` --model '${escapedModel}'`;
|
|
1677
1589
|
}
|
|
1678
|
-
if (
|
|
1679
|
-
|
|
1590
|
+
if (mode === "full-access") {
|
|
1591
|
+
if (!/--dangerously-bypass-approvals-and-sandbox(?:\s|$)/.test(result)) {
|
|
1592
|
+
result += " --dangerously-bypass-approvals-and-sandbox";
|
|
1593
|
+
}
|
|
1680
1594
|
}
|
|
1681
|
-
return
|
|
1595
|
+
return result;
|
|
1682
1596
|
}
|
|
1683
1597
|
const isClaudeCmd = /^(?:claude|npx\s+claude|[^\s]+\/claude)(?:\s|$)/.test(command);
|
|
1684
1598
|
if (!isClaudeCmd)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
const CLAUDE_TIMEOUT_MS = 60_000;
|
|
3
|
+
const MAX_INPUT_LENGTH = 8000;
|
|
4
|
+
export class PromptOptimizeError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(message, code) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = "PromptOptimizeError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function callClaudeText(prompt, cwd) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const child = execFile("claude", ["-p", "--output-format", "text"], {
|
|
15
|
+
cwd: cwd && cwd.length > 0 ? cwd : undefined,
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
18
|
+
timeout: CLAUDE_TIMEOUT_MS,
|
|
19
|
+
}, (error, stdout, stderr) => {
|
|
20
|
+
if (error) {
|
|
21
|
+
const e = error;
|
|
22
|
+
if (e.code === "ENOENT") {
|
|
23
|
+
reject(new PromptOptimizeError("未找到 claude CLI。", "CLAUDE_CLI_MISSING"));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (e.code === "ETIMEDOUT") {
|
|
27
|
+
reject(new PromptOptimizeError("Claude 优化超时,请稍后重试。", "CLAUDE_TIMEOUT"));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const msg = (stderr || "").trim() || e.message || "claude 调用失败";
|
|
31
|
+
reject(new PromptOptimizeError(`Claude CLI 失败:${msg}`, "CLAUDE_CLI_FAILED"));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
resolve((stdout || "").trim());
|
|
35
|
+
});
|
|
36
|
+
child.stdin?.end(prompt);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function buildOptimizePrompt(userInput, language) {
|
|
40
|
+
const lang = (language || "").trim() || "中文";
|
|
41
|
+
return [
|
|
42
|
+
`你是一名提示词优化助手。请把用户写给编码 AI 的「原始提示词」改写得更清晰、结构化、可执行,便于 AI 理解并完成任务。`,
|
|
43
|
+
`要求:`,
|
|
44
|
+
`1. 保留用户原意和所有关键信息(文件路径、变量名、技术名词、数字、约束等),不要删减事实,也不要新增臆测的需求。`,
|
|
45
|
+
`2. 必要时拆分为「目标 / 上下文 / 约束 / 验收标准」几个部分;如果原文很短或很简单,则只做语句润色,不要硬塞结构。`,
|
|
46
|
+
`3. 用${lang}输出。语气克制专业,不寒暄、不解释你做了什么。`,
|
|
47
|
+
`4. 只输出优化后的提示词正文,不要包裹在代码块或引号里,不要加任何前后缀(比如「优化后:」之类)。`,
|
|
48
|
+
``,
|
|
49
|
+
`原始提示词:`,
|
|
50
|
+
userInput,
|
|
51
|
+
].join("\n");
|
|
52
|
+
}
|
|
53
|
+
export async function optimizePrompt(rawText, language, cwd) {
|
|
54
|
+
const text = (rawText || "").trim();
|
|
55
|
+
if (!text) {
|
|
56
|
+
throw new PromptOptimizeError("请先输入要优化的内容。", "EMPTY_INPUT");
|
|
57
|
+
}
|
|
58
|
+
if (text.length > MAX_INPUT_LENGTH) {
|
|
59
|
+
throw new PromptOptimizeError(`输入过长(${text.length} 字符),请缩短到 ${MAX_INPUT_LENGTH} 以内。`, "INPUT_TOO_LONG");
|
|
60
|
+
}
|
|
61
|
+
const prompt = buildOptimizePrompt(text, language);
|
|
62
|
+
const raw = await callClaudeText(prompt, cwd);
|
|
63
|
+
const cleaned = raw
|
|
64
|
+
.replace(/^```[a-zA-Z]*\n?/, "")
|
|
65
|
+
.replace(/\n?```$/, "")
|
|
66
|
+
.replace(/^["'`]+|["'`]+$/g, "")
|
|
67
|
+
.trim();
|
|
68
|
+
if (!cleaned) {
|
|
69
|
+
throw new PromptOptimizeError("Claude 返回了空结果。", "EMPTY_RESULT");
|
|
70
|
+
}
|
|
71
|
+
return cleaned;
|
|
72
|
+
}
|