@co0ontty/wand 1.18.1 → 1.20.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/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 +373 -0
- package/dist/process-manager.d.ts +6 -9
- package/dist/process-manager.js +26 -195
- package/dist/prompt-optimizer.d.ts +5 -0
- package/dist/prompt-optimizer.js +72 -0
- package/dist/pty-text-utils.d.ts +1 -3
- package/dist/pty-text-utils.js +1 -3
- package/dist/server-session-routes.d.ts +2 -2
- package/dist/server-session-routes.js +79 -13
- package/dist/server.d.ts +19 -1
- package/dist/server.js +90 -5
- package/dist/storage.js +4 -8
- package/dist/structured-session-manager.d.ts +6 -1
- package/dist/structured-session-manager.js +156 -13
- 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 +42 -14
- package/dist/web-ui/content/scripts.js +1188 -209
- package/dist/web-ui/content/styles.css +536 -19
- package/dist/web-ui/content/vendor/wterm/wterm.bundle.js +1 -1
- package/package.json +3 -1
package/dist/process-manager.js
CHANGED
|
@@ -6,12 +6,11 @@ import process from "node:process";
|
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import pty from "node-pty";
|
|
8
8
|
import { SessionLogger } from "./session-logger.js";
|
|
9
|
-
import { SessionLifecycleManager } from "./session-lifecycle.js";
|
|
10
9
|
import { ClaudePtyBridge } from "./claude-pty-bridge.js";
|
|
11
10
|
import { truncateMessagesForTransport } from "./message-truncator.js";
|
|
12
11
|
import { appendWindow, hasExplicitConfirmSyntax, hasPermissionActionContext, normalizePromptText } from "./pty-text-utils.js";
|
|
13
12
|
import { prepareSessionWorktree } from "./git-worktree.js";
|
|
14
|
-
import { getResumeCommandSessionId
|
|
13
|
+
import { getResumeCommandSessionId } from "./resume-policy.js";
|
|
15
14
|
function resolveProviderFromCommand(command) {
|
|
16
15
|
return /^codex\b/.test(command.trim()) ? "codex" : "claude";
|
|
17
16
|
}
|
|
@@ -128,52 +127,8 @@ function selectClaudeProjectSessionForRecord(record) {
|
|
|
128
127
|
}
|
|
129
128
|
return candidates[0] ?? null;
|
|
130
129
|
}
|
|
131
|
-
/**
|
|
132
|
-
* Broader fallback: find a JSONL file by mtime proximity when strict
|
|
133
|
-
* mtime-correlation fails (e.g., file existed before session but Claude
|
|
134
|
-
* wrote conversation content during this session).
|
|
135
|
-
* Looks for the most recently modified file that was active near the
|
|
136
|
-
* session's start time and has real conversation content.
|
|
137
|
-
*/
|
|
138
|
-
function selectClaudeProjectSessionByProximity(record) {
|
|
139
|
-
const hasUserTurn = record.messages.some((turn) => turn.role === "user"
|
|
140
|
-
&& turn.content.some((block) => block.type === "text" && block.text.trim().length > 0));
|
|
141
|
-
if (!hasUserTurn) {
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
const startedAtMs = Date.parse(record.startedAt);
|
|
145
|
-
const now = Date.now();
|
|
146
|
-
// Look for files modified from ~60s before session start up to now
|
|
147
|
-
const proximityWindowMs = 60 * 1000;
|
|
148
|
-
const candidates = listClaudeProjectSessionCandidates(record.cwd)
|
|
149
|
-
.filter((candidate) => {
|
|
150
|
-
if (!Number.isFinite(startedAtMs))
|
|
151
|
-
return true;
|
|
152
|
-
return candidate.mtimeMs >= startedAtMs - proximityWindowMs
|
|
153
|
-
&& candidate.mtimeMs <= now + DISCOVERY_RECENT_WINDOW_MS;
|
|
154
|
-
})
|
|
155
|
-
.map((candidate) => readClaudeProjectSessionDetails(candidate.filePath, candidate.id))
|
|
156
|
-
.filter((candidate) => Boolean(candidate?.hasConversation))
|
|
157
|
-
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
158
|
-
return candidates[0] ?? null;
|
|
159
|
-
}
|
|
160
|
-
function getResumeEligibility(record) {
|
|
161
|
-
const hasClaudeSessionId = Boolean(record.claudeSessionId);
|
|
162
|
-
const hasRealConversation = hasRealConversationMessages(record.messages);
|
|
163
|
-
return {
|
|
164
|
-
hasClaudeSessionId,
|
|
165
|
-
hasRealConversation,
|
|
166
|
-
eligible: hasClaudeSessionId && hasRealConversation
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
function hasResumeEligibleConversation(record) {
|
|
170
|
-
return getResumeEligibility(record).eligible;
|
|
171
|
-
}
|
|
172
130
|
function getLatestClaudeProjectSessionId(record) {
|
|
173
|
-
|
|
174
|
-
return selectClaudeProjectSessionForRecord(record)?.id
|
|
175
|
-
?? selectClaudeProjectSessionByProximity(record)?.id
|
|
176
|
-
?? null;
|
|
131
|
+
return selectClaudeProjectSessionForRecord(record)?.id ?? null;
|
|
177
132
|
}
|
|
178
133
|
function listRecentClaudeProjectSessionIds(cwd, startedAt) {
|
|
179
134
|
return listClaudeProjectSessionCandidates(cwd)
|
|
@@ -181,33 +136,6 @@ function listRecentClaudeProjectSessionIds(cwd, startedAt) {
|
|
|
181
136
|
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
182
137
|
.map((candidate) => candidate.id);
|
|
183
138
|
}
|
|
184
|
-
function findRealClaudeProjectSessionId(cwd, startedAt) {
|
|
185
|
-
// Strict mtime-based discovery first
|
|
186
|
-
const candidates = listRecentClaudeProjectSessionIds(cwd, startedAt)
|
|
187
|
-
.map((id) => {
|
|
188
|
-
const filePath = path.join(getClaudeProjectDir(cwd), `${id}.jsonl`);
|
|
189
|
-
return readClaudeProjectSessionDetails(filePath, id);
|
|
190
|
-
})
|
|
191
|
-
.filter((candidate) => Boolean(candidate?.hasConversation))
|
|
192
|
-
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
193
|
-
if (candidates.length > 0)
|
|
194
|
-
return candidates[0].id;
|
|
195
|
-
// Fallback: broader proximity search for files with conversation content
|
|
196
|
-
const startedAtMs = Date.parse(startedAt);
|
|
197
|
-
const now = Date.now();
|
|
198
|
-
const proximityWindowMs = 60 * 1000;
|
|
199
|
-
const proximityCandidates = listClaudeProjectSessionCandidates(cwd)
|
|
200
|
-
.filter((candidate) => {
|
|
201
|
-
if (!Number.isFinite(startedAtMs))
|
|
202
|
-
return true;
|
|
203
|
-
return candidate.mtimeMs >= startedAtMs - proximityWindowMs
|
|
204
|
-
&& candidate.mtimeMs <= now + DISCOVERY_RECENT_WINDOW_MS;
|
|
205
|
-
})
|
|
206
|
-
.map((candidate) => readClaudeProjectSessionDetails(candidate.filePath, candidate.id))
|
|
207
|
-
.filter((candidate) => Boolean(candidate?.hasConversation))
|
|
208
|
-
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
209
|
-
return proximityCandidates[0]?.id ?? null;
|
|
210
|
-
}
|
|
211
139
|
function isClaudeSessionFileAvailable(cwd, claudeSessionId) {
|
|
212
140
|
const filePath = path.join(getClaudeProjectDir(cwd), `${claudeSessionId}.jsonl`);
|
|
213
141
|
return Boolean(readClaudeProjectSessionDetails(filePath, claudeSessionId));
|
|
@@ -349,19 +277,6 @@ function listAllClaudeHistorySessions() {
|
|
|
349
277
|
return [];
|
|
350
278
|
}
|
|
351
279
|
}
|
|
352
|
-
function shouldAutoResumeSession(record) {
|
|
353
|
-
return record.status === "exited"
|
|
354
|
-
&& !record.archived
|
|
355
|
-
&& !record.resumedToSessionId
|
|
356
|
-
&& record.ptyProcess === null
|
|
357
|
-
&& hasResumeEligibleConversation(record);
|
|
358
|
-
}
|
|
359
|
-
function shouldBackfillClaudeSessionId(record) {
|
|
360
|
-
return record.status === "exited"
|
|
361
|
-
&& !record.claudeSessionId
|
|
362
|
-
&& /^claude\b/.test(record.command.trim())
|
|
363
|
-
&& hasRealConversationMessages(record.messages);
|
|
364
|
-
}
|
|
365
280
|
function snapshotMessages(record) {
|
|
366
281
|
return record.ptyBridge?.getMessages() ?? record.messages;
|
|
367
282
|
}
|
|
@@ -451,26 +366,19 @@ export class ProcessManager extends EventEmitter {
|
|
|
451
366
|
storage;
|
|
452
367
|
sessions = new Map();
|
|
453
368
|
logger;
|
|
454
|
-
|
|
369
|
+
/** 24h archive scan timer */
|
|
370
|
+
archiveTimer = null;
|
|
455
371
|
/** Per-session debounce timers for throttled persist calls */
|
|
456
372
|
persistDebounceTimers = new Map();
|
|
457
373
|
/** Last persisted message state per session — used to skip redundant message writes */
|
|
458
374
|
lastPersistedMessageState = new Map();
|
|
375
|
+
/** 启动时被识别为孤儿 PTY 并标记为 exited 的旧会话数(旧服务器进程已死) */
|
|
376
|
+
orphanRecoveredCount = 0;
|
|
459
377
|
constructor(config, storage, configDir) {
|
|
460
378
|
super();
|
|
461
379
|
this.config = config;
|
|
462
380
|
this.storage = storage;
|
|
463
381
|
this.logger = new SessionLogger(configDir || path.join(process.env.HOME || process.cwd(), ".wand"), config.shortcutLogMaxBytes);
|
|
464
|
-
// Initialize lifecycle manager
|
|
465
|
-
this.lifecycleManager = new SessionLifecycleManager({
|
|
466
|
-
onStateChange: (sessionId, oldState, newState) => {
|
|
467
|
-
this.emitEvent({ type: "status", sessionId, data: { oldState, newState } });
|
|
468
|
-
},
|
|
469
|
-
onIdle: (_sessionId) => { },
|
|
470
|
-
onArchived: (sessionId, reason) => {
|
|
471
|
-
console.error(`[ProcessManager] Session ${sessionId} archived: ${reason}`);
|
|
472
|
-
},
|
|
473
|
-
});
|
|
474
382
|
for (const snapshot of this.storage.loadSessions()) {
|
|
475
383
|
if ((snapshot.sessionKind ?? "pty") !== "pty") {
|
|
476
384
|
continue;
|
|
@@ -520,8 +428,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
520
428
|
claudeSessionId: resumeCommandSessionId ?? updated.claudeSessionId,
|
|
521
429
|
approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
|
|
522
430
|
});
|
|
523
|
-
this.
|
|
524
|
-
console.error(`[ProcessManager] Restored session ${snapshot.id} marked as exited (PTY orphaned)`);
|
|
431
|
+
this.orphanRecoveredCount += 1;
|
|
525
432
|
}
|
|
526
433
|
else {
|
|
527
434
|
this.sessions.set(snapshot.id, {
|
|
@@ -554,20 +461,26 @@ export class ProcessManager extends EventEmitter {
|
|
|
554
461
|
claudeSessionId: resumeCommandSessionId ?? snapshot.claudeSessionId,
|
|
555
462
|
approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
|
|
556
463
|
});
|
|
557
|
-
this.lifecycleManager.register(snapshot.id, "archived");
|
|
558
464
|
}
|
|
559
465
|
}
|
|
560
|
-
// Defer expensive file-system scanning and auto-recovery so the server
|
|
561
|
-
// can start responding to requests immediately.
|
|
562
|
-
setImmediate(() => {
|
|
563
|
-
this.backfillExitedClaudeSessionIds();
|
|
564
|
-
this.autoRecoverExitedSessions();
|
|
565
|
-
});
|
|
566
466
|
this.archiveExpiredSessions();
|
|
467
|
+
this.archiveTimer = setInterval(() => {
|
|
468
|
+
try {
|
|
469
|
+
this.archiveExpiredSessions();
|
|
470
|
+
}
|
|
471
|
+
catch (err) {
|
|
472
|
+
console.error(`[ProcessManager] archive scan failed: ${String(err)}`);
|
|
473
|
+
}
|
|
474
|
+
}, 60 * 1000);
|
|
475
|
+
this.archiveTimer.unref?.();
|
|
567
476
|
}
|
|
568
477
|
on(event, listener) {
|
|
569
478
|
return super.on("process", listener);
|
|
570
479
|
}
|
|
480
|
+
/** 启动时被识别为孤儿 PTY 并标记为 exited 的旧会话数量(仅用于启动摘要展示)。 */
|
|
481
|
+
getOrphanRecoveredCount() {
|
|
482
|
+
return this.orphanRecoveredCount;
|
|
483
|
+
}
|
|
571
484
|
emitEvent(event) {
|
|
572
485
|
this.emit("process", event);
|
|
573
486
|
}
|
|
@@ -670,7 +583,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
670
583
|
ptyPermissionBlocked: false,
|
|
671
584
|
lastAutoConfirmAt: 0,
|
|
672
585
|
autoApprovePermissions: this.shouldAutoApprovePermissions(command, effectiveMode, provider),
|
|
673
|
-
resumedFromSessionId: opts?.resumedFromSessionId ?? null,
|
|
586
|
+
resumedFromSessionId: opts?.resumedFromSessionId ?? (opts?.reuseId ? opts.reuseId : null),
|
|
674
587
|
autoRecovered: opts?.autoRecovered ?? false,
|
|
675
588
|
rememberedEscalationScopes: new Set(),
|
|
676
589
|
rememberedEscalationTargets: new Set(),
|
|
@@ -704,7 +617,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
704
617
|
this.claudeHistoryCache = null;
|
|
705
618
|
}
|
|
706
619
|
this.cleanupOldSessions();
|
|
707
|
-
this.lifecycleManager.register(id, "initializing");
|
|
708
620
|
const shellArgs = this.buildShellArgs(processedCommand);
|
|
709
621
|
let child;
|
|
710
622
|
try {
|
|
@@ -727,14 +639,12 @@ export class ProcessManager extends EventEmitter {
|
|
|
727
639
|
record.exitCode = -1;
|
|
728
640
|
record.endedAt = new Date().toISOString();
|
|
729
641
|
record.ptyProcess = null;
|
|
730
|
-
this.lifecycleManager.archive(id, "Session spawn failed", "error");
|
|
731
642
|
this.persist(record);
|
|
732
643
|
return this.snapshot(record);
|
|
733
644
|
}
|
|
734
645
|
record.processId = child.pid;
|
|
735
646
|
record.ptyProcess = child;
|
|
736
647
|
record.status = "running";
|
|
737
|
-
this.lifecycleManager.setState(id, "running");
|
|
738
648
|
child.onExit(({ exitCode }) => {
|
|
739
649
|
const current = this.sessions.get(id);
|
|
740
650
|
if (!current)
|
|
@@ -757,7 +667,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
757
667
|
current.exitCode = current.stopRequested ? null : exitCode;
|
|
758
668
|
current.endedAt = new Date().toISOString();
|
|
759
669
|
current.ptyProcess = null;
|
|
760
|
-
this.lifecycleManager.archive(id, `Session ${current.status}`, current.stopRequested ? "user" : "error");
|
|
761
670
|
this.flushPersist(current);
|
|
762
671
|
this.storage.saveSession(this.snapshot(current));
|
|
763
672
|
this.emitEvent({ type: "ended", sessionId: id, data: this.snapshot(current) });
|
|
@@ -877,14 +786,12 @@ export class ProcessManager extends EventEmitter {
|
|
|
877
786
|
return this.snapshot(record);
|
|
878
787
|
}
|
|
879
788
|
list() {
|
|
880
|
-
this.archiveExpiredSessions();
|
|
881
789
|
return Array.from(this.sessions.values())
|
|
882
790
|
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))
|
|
883
791
|
.map((session) => this.snapshot(session));
|
|
884
792
|
}
|
|
885
793
|
/** Return lightweight snapshots for the session list (no output/messages). */
|
|
886
794
|
listSlim() {
|
|
887
|
-
this.archiveExpiredSessions();
|
|
888
795
|
return Array.from(this.sessions.values())
|
|
889
796
|
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))
|
|
890
797
|
.map((session) => this.snapshotSlim(session));
|
|
@@ -947,18 +854,15 @@ export class ProcessManager extends EventEmitter {
|
|
|
947
854
|
return deleted;
|
|
948
855
|
}
|
|
949
856
|
get(id) {
|
|
950
|
-
this.archiveExpiredSessions();
|
|
951
857
|
const record = this.sessions.get(id);
|
|
952
858
|
if (!record) {
|
|
953
|
-
// Fallback: check SQLite for sessions that were evicted from memory
|
|
954
859
|
return this.storage.getSession(id) ?? null;
|
|
955
860
|
}
|
|
956
|
-
|
|
957
|
-
// Prefer in-memory output (live PTY data), fall back to stored output.
|
|
861
|
+
const result = this.snapshot(record);
|
|
958
862
|
if (!record.output && record.storedOutput) {
|
|
959
|
-
|
|
863
|
+
result.output = record.storedOutput;
|
|
960
864
|
}
|
|
961
|
-
return
|
|
865
|
+
return result;
|
|
962
866
|
}
|
|
963
867
|
getPtyTranscript(id) {
|
|
964
868
|
return this.logger.readPtyOutput(id);
|
|
@@ -990,8 +894,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
990
894
|
throw new SessionInputError("Session is not running.", "SESSION_NOT_RUNNING", id, record.status);
|
|
991
895
|
}
|
|
992
896
|
// Update lifecycle
|
|
993
|
-
this.lifecycleManager.touch(id);
|
|
994
|
-
this.lifecycleManager.startThinking(id);
|
|
995
897
|
if (!record.ptyProcess) {
|
|
996
898
|
console.error(`[ProcessManager] Rejecting input: session ${id} has no PTY`);
|
|
997
899
|
throw new SessionInputError("Session is not running.", "SESSION_NO_PTY", id, record.status);
|
|
@@ -1003,7 +905,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
1003
905
|
const ctx = {
|
|
1004
906
|
mode: record.mode,
|
|
1005
907
|
autoApprove: record.autoApprovePermissions,
|
|
1006
|
-
permissionBlocked:
|
|
908
|
+
permissionBlocked: this.isPermissionBlocked(record),
|
|
1007
909
|
input,
|
|
1008
910
|
};
|
|
1009
911
|
this.logger.appendShortcutLog(id, shortcutKey, tailLines, ctx);
|
|
@@ -1093,7 +995,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
1093
995
|
record.ptyBridge = null;
|
|
1094
996
|
}
|
|
1095
997
|
// Update lifecycle
|
|
1096
|
-
this.lifecycleManager.archive(id, "Session stopped by user", "user");
|
|
1097
998
|
this.persist(record);
|
|
1098
999
|
return this.snapshot(record);
|
|
1099
1000
|
}
|
|
@@ -1130,7 +1031,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
1130
1031
|
record.ptyBridge.removeAllListeners();
|
|
1131
1032
|
record.ptyBridge = null;
|
|
1132
1033
|
}
|
|
1133
|
-
this.lifecycleManager.unregister(record.id);
|
|
1134
1034
|
}
|
|
1135
1035
|
delete(id) {
|
|
1136
1036
|
const record = this.mustGet(id);
|
|
@@ -1183,7 +1083,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
1183
1083
|
this.deleteClaudeCache(record);
|
|
1184
1084
|
this.sessions.delete(id);
|
|
1185
1085
|
this.lastPersistedMessageState.delete(id);
|
|
1186
|
-
this.lifecycleManager.unregister(id);
|
|
1187
1086
|
if (record.claudeSessionId) {
|
|
1188
1087
|
this.claudeHistoryCache = null;
|
|
1189
1088
|
}
|
|
@@ -1246,7 +1145,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
1246
1145
|
claudeSessionId: record.claudeSessionId || null,
|
|
1247
1146
|
messages: messages.length > 0 ? messages : undefined,
|
|
1248
1147
|
resumedFromSessionId: record.resumedFromSessionId ?? undefined,
|
|
1249
|
-
resumedToSessionId: record.resumedToSessionId ?? undefined,
|
|
1250
1148
|
autoRecovered: record.autoRecovered ?? false,
|
|
1251
1149
|
autoApprovePermissions: record.autoApprovePermissions || undefined,
|
|
1252
1150
|
approvalStats: record.approvalStats.total > 0 ? record.approvalStats : undefined,
|
|
@@ -1265,7 +1163,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
1265
1163
|
};
|
|
1266
1164
|
}
|
|
1267
1165
|
isPermissionBlocked(record) {
|
|
1268
|
-
return record.
|
|
1166
|
+
return record.ptyPermissionBlocked || record.pendingEscalation !== null;
|
|
1269
1167
|
}
|
|
1270
1168
|
defaultAutonomyPolicy(mode) {
|
|
1271
1169
|
if (mode === "agent" || mode === "agent-max" || mode === "managed" || mode === "native" || mode === "full-access") {
|
|
@@ -1357,7 +1255,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
1357
1255
|
endedAt: record.endedAt,
|
|
1358
1256
|
claudeSessionId: record.claudeSessionId,
|
|
1359
1257
|
resumedFromSessionId: record.resumedFromSessionId ?? null,
|
|
1360
|
-
resumedToSessionId: record.resumedToSessionId ?? null,
|
|
1361
1258
|
autoRecovered: record.autoRecovered ?? false,
|
|
1362
1259
|
});
|
|
1363
1260
|
if (shouldSaveMessages) {
|
|
@@ -1392,70 +1289,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
1392
1289
|
}
|
|
1393
1290
|
this.persist(record);
|
|
1394
1291
|
}
|
|
1395
|
-
backfillExitedClaudeSessionIds() {
|
|
1396
|
-
for (const record of this.sessions.values()) {
|
|
1397
|
-
record.messages = snapshotMessages(record);
|
|
1398
|
-
if (!shouldBackfillClaudeSessionId(record)) {
|
|
1399
|
-
continue;
|
|
1400
|
-
}
|
|
1401
|
-
const discoveredSessionId = findRealClaudeProjectSessionId(record.cwd, record.startedAt);
|
|
1402
|
-
if (!discoveredSessionId) {
|
|
1403
|
-
continue;
|
|
1404
|
-
}
|
|
1405
|
-
record.claudeSessionId = discoveredSessionId;
|
|
1406
|
-
this.persist(record);
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
/**
|
|
1410
|
-
* Auto-recover the most recent exited session that has a Claude session ID.
|
|
1411
|
-
* Only resumes one session per server start, using the most recent eligible
|
|
1412
|
-
* session. Reuses the original session ID (in-place resume) and sets
|
|
1413
|
-
* `autoRecovered: true`.
|
|
1414
|
-
*/
|
|
1415
|
-
autoRecoverExitedSessions() {
|
|
1416
|
-
// Find eligible exited sessions
|
|
1417
|
-
const eligibleSessions = [];
|
|
1418
|
-
for (const record of this.sessions.values()) {
|
|
1419
|
-
record.messages = snapshotMessages(record);
|
|
1420
|
-
if (shouldAutoResumeSession(record)) {
|
|
1421
|
-
eligibleSessions.push(record);
|
|
1422
|
-
}
|
|
1423
|
-
}
|
|
1424
|
-
if (eligibleSessions.length === 0)
|
|
1425
|
-
return;
|
|
1426
|
-
// Sort by startedAt descending (most recent first)
|
|
1427
|
-
eligibleSessions.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
1428
|
-
// Only auto-recover the single most recent session
|
|
1429
|
-
const original = eligibleSessions[0];
|
|
1430
|
-
const isClaude = /^claude\b/.test(original.command.trim());
|
|
1431
|
-
if (!isClaude)
|
|
1432
|
-
return;
|
|
1433
|
-
// If no claudeSessionId is bound yet, try to discover it via proximity search
|
|
1434
|
-
if (!original.claudeSessionId) {
|
|
1435
|
-
const discovered = findRealClaudeProjectSessionId(original.cwd, original.startedAt);
|
|
1436
|
-
if (discovered) {
|
|
1437
|
-
original.claudeSessionId = discovered;
|
|
1438
|
-
process.stderr.write(`[wand] Backfilled Claude session ID for auto-recovery: ${discovered}\n`);
|
|
1439
|
-
this.persist(original);
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
if (!original.claudeSessionId) {
|
|
1443
|
-
console.error(`[ProcessManager] Skipping auto-recovery: no Claude session ID for session ${original.id}`);
|
|
1444
|
-
return;
|
|
1445
|
-
}
|
|
1446
|
-
console.error(`[ProcessManager] Auto-recovering session ${original.id} with Claude session ID ${original.claudeSessionId}`);
|
|
1447
|
-
const resumeCommand = `${original.command.trim()} --resume ${original.claudeSessionId}`;
|
|
1448
|
-
try {
|
|
1449
|
-
const snapshot = this.start(resumeCommand, original.cwd, original.mode, undefined, {
|
|
1450
|
-
reuseId: original.id,
|
|
1451
|
-
autoRecovered: true
|
|
1452
|
-
});
|
|
1453
|
-
console.error(`[ProcessManager] Auto-recovered session ${snapshot.id} (in-place)`);
|
|
1454
|
-
}
|
|
1455
|
-
catch (err) {
|
|
1456
|
-
console.error(`[ProcessManager] Auto-recovery failed: ${String(err)}`);
|
|
1457
|
-
}
|
|
1458
|
-
}
|
|
1459
1292
|
archiveExpiredSessions() {
|
|
1460
1293
|
const now = Date.now();
|
|
1461
1294
|
for (const record of this.sessions.values()) {
|
|
@@ -1637,8 +1470,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
1637
1470
|
record.ptyBridge?.clearRememberedPermissions();
|
|
1638
1471
|
record.rememberedEscalationScopes.clear();
|
|
1639
1472
|
record.rememberedEscalationTargets.clear();
|
|
1640
|
-
this.lifecycleManager.stopThinking(record.id);
|
|
1641
|
-
this.lifecycleManager.waitingInput(record.id);
|
|
1642
1473
|
this.persist(record);
|
|
1643
1474
|
this.storage.saveSession(this.snapshot(record));
|
|
1644
1475
|
break;
|
|
@@ -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
|
+
}
|
package/dist/pty-text-utils.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared PTY text processing utilities.
|
|
3
|
-
* Used by both claude-pty-bridge.ts and message-parser.ts to ensure
|
|
4
|
-
* consistent ANSI stripping and noise filtering.
|
|
2
|
+
* Shared PTY text processing utilities for consistent ANSI stripping and noise filtering.
|
|
5
3
|
*/
|
|
6
4
|
/** Strip ANSI escape sequences and control characters from raw PTY output. */
|
|
7
5
|
export declare function stripAnsi(text: string): string;
|
package/dist/pty-text-utils.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared PTY text processing utilities.
|
|
3
|
-
* Used by both claude-pty-bridge.ts and message-parser.ts to ensure
|
|
4
|
-
* consistent ANSI stripping and noise filtering.
|
|
2
|
+
* Shared PTY text processing utilities for consistent ANSI stripping and noise filtering.
|
|
5
3
|
*/
|
|
6
4
|
/** Strip ANSI escape sequences and control characters from raw PTY output. */
|
|
7
5
|
export function stripAnsi(text) {
|
|
@@ -2,7 +2,7 @@ import { Express } from "express";
|
|
|
2
2
|
import { ProcessManager } from "./process-manager.js";
|
|
3
3
|
import { StructuredSessionManager } from "./structured-session-manager.js";
|
|
4
4
|
import { WandStorage } from "./storage.js";
|
|
5
|
-
import { ExecutionMode } from "./types.js";
|
|
5
|
+
import { ExecutionMode, WandConfig } from "./types.js";
|
|
6
6
|
export declare function getErrorMessage(error: unknown, fallback: string): string;
|
|
7
|
-
export declare function registerSessionRoutes(app: Express, processes: ProcessManager, structured: StructuredSessionManager, storage: WandStorage, defaultMode: ExecutionMode): void;
|
|
7
|
+
export declare function registerSessionRoutes(app: Express, processes: ProcessManager, structured: StructuredSessionManager, storage: WandStorage, defaultMode: ExecutionMode, config: WandConfig): void;
|
|
8
8
|
export declare function registerClaudeHistoryRoutes(app: Express, processes: ProcessManager, storage: WandStorage): void;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
-
import { parseMessages } from "./message-parser.js";
|
|
3
2
|
import { SessionInputError } from "./process-manager.js";
|
|
4
3
|
import { checkSessionWorktreeMergeability, cleanupSessionWorktree, getWorktreeMergeErrorCode, mergeSessionWorktree, WorktreeMergeError } from "./git-worktree.js";
|
|
4
|
+
import { getGitStatus, QuickCommitError, runQuickCommit, generateCommitMessageOnly } from "./git-quick-commit.js";
|
|
5
5
|
export function getErrorMessage(error, fallback) {
|
|
6
6
|
return error instanceof Error ? error.message : fallback;
|
|
7
7
|
}
|
|
@@ -136,7 +136,7 @@ function canMergeSession(snapshot) {
|
|
|
136
136
|
function isMergeActionAllowed(snapshot) {
|
|
137
137
|
return snapshot.status !== "running";
|
|
138
138
|
}
|
|
139
|
-
export function registerSessionRoutes(app, processes, structured, storage, defaultMode) {
|
|
139
|
+
export function registerSessionRoutes(app, processes, structured, storage, defaultMode, config) {
|
|
140
140
|
app.get("/api/sessions", (_req, res) => {
|
|
141
141
|
const all = listAllSessionsSlim(processes, structured);
|
|
142
142
|
console.log("[WAND] GET /api/sessions count:", all.length, "sessions:", all.map(s => ({ id: s.id.substring(0, 8), kind: s.sessionKind, runner: s.runner, status: s.status })));
|
|
@@ -198,9 +198,10 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
198
198
|
});
|
|
199
199
|
app.post("/api/structured-sessions/:id/messages", express.json(), async (req, res) => {
|
|
200
200
|
const input = String(req.body?.input ?? "");
|
|
201
|
-
|
|
201
|
+
const interrupt = !!req.body?.interrupt;
|
|
202
|
+
console.log("[WAND] POST /api/structured-sessions/:id/messages id:", req.params.id, "input:", input.substring(0, 50), "interrupt:", interrupt);
|
|
202
203
|
try {
|
|
203
|
-
const snapshot = await structured.sendMessage(req.params.id, input);
|
|
204
|
+
const snapshot = await structured.sendMessage(req.params.id, input, { interrupt });
|
|
204
205
|
res.json(snapshot);
|
|
205
206
|
}
|
|
206
207
|
catch (error) {
|
|
@@ -302,6 +303,77 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
302
303
|
res.status(getWorktreeMergeResponseStatus(error)).json(getWorktreeMergePayload(error, "无法合并 worktree。"));
|
|
303
304
|
}
|
|
304
305
|
});
|
|
306
|
+
app.get("/api/sessions/:id/git-status", (req, res) => {
|
|
307
|
+
const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
|
|
308
|
+
if (!snapshot) {
|
|
309
|
+
res.status(404).json({ error: "未找到该会话。" });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!snapshot.cwd) {
|
|
313
|
+
res.json({ isGit: false });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
res.json(getGitStatus(snapshot.cwd));
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
res.json({ isGit: false, error: getErrorMessage(error, "无法读取 git 状态。") });
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
app.post("/api/sessions/:id/quick-commit", express.json(), async (req, res) => {
|
|
324
|
+
const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
|
|
325
|
+
if (!snapshot) {
|
|
326
|
+
res.status(404).json({ error: "未找到该会话。" });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (!snapshot.cwd) {
|
|
330
|
+
res.status(400).json({ error: "会话没有工作目录。", errorCode: "NO_CWD" });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const body = (req.body ?? {});
|
|
334
|
+
try {
|
|
335
|
+
const result = await runQuickCommit({
|
|
336
|
+
cwd: snapshot.cwd,
|
|
337
|
+
language: config.language ?? "",
|
|
338
|
+
autoMessage: body.autoMessage !== false,
|
|
339
|
+
customMessage: typeof body.customMessage === "string" ? body.customMessage : undefined,
|
|
340
|
+
tag: typeof body.tag === "string" ? body.tag : undefined,
|
|
341
|
+
autoTag: !!body.autoTag,
|
|
342
|
+
push: !!body.push,
|
|
343
|
+
});
|
|
344
|
+
res.json(result);
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
if (error instanceof QuickCommitError) {
|
|
348
|
+
const status = error.code === "NOTHING_TO_COMMIT" || error.code === "TAG_EXISTS" ? 409 : 400;
|
|
349
|
+
res.status(status).json({ error: error.message, errorCode: error.code });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
res.status(400).json({ error: getErrorMessage(error, "快捷提交失败。") });
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
app.post("/api/sessions/:id/generate-commit-message", express.json(), async (req, res) => {
|
|
356
|
+
const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
|
|
357
|
+
if (!snapshot) {
|
|
358
|
+
res.status(404).json({ error: "未找到该会话。" });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (!snapshot.cwd) {
|
|
362
|
+
res.status(400).json({ error: "会话没有工作目录。" });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
const message = await generateCommitMessageOnly(snapshot.cwd, config.language ?? "");
|
|
367
|
+
res.json({ message });
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
if (error instanceof QuickCommitError) {
|
|
371
|
+
res.status(400).json({ error: error.message, errorCode: error.code });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
res.status(400).json({ error: getErrorMessage(error, "生成 commit message 失败。") });
|
|
375
|
+
}
|
|
376
|
+
});
|
|
305
377
|
app.post("/api/sessions/:id/worktree/cleanup", (req, res) => {
|
|
306
378
|
try {
|
|
307
379
|
const current = requireWorktreeSession(getLatestSessionSnapshot(processes, structured, storage, req.params.id));
|
|
@@ -362,13 +434,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
362
434
|
? processes.getPtyTranscript(snapshot.id) ?? snapshot.output
|
|
363
435
|
: snapshot.output;
|
|
364
436
|
if (req.query.format === "chat") {
|
|
365
|
-
const
|
|
366
|
-
const fallbackOutput = allowFallback ? transcriptOutput : "";
|
|
367
|
-
const messages = snapshot.messages && snapshot.messages.length > 0
|
|
368
|
-
? snapshot.messages
|
|
369
|
-
: allowFallback
|
|
370
|
-
? parseMessages(fallbackOutput, snapshot.command)
|
|
371
|
-
: [];
|
|
437
|
+
const messages = snapshot.messages ?? [];
|
|
372
438
|
res.json({ ...snapshot, output: transcriptOutput, messages });
|
|
373
439
|
}
|
|
374
440
|
else {
|
|
@@ -407,7 +473,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
407
473
|
const newMode = body.mode ? normalizeMode(body.mode, defaultMode) : normalizeMode(existingSession.mode, defaultMode);
|
|
408
474
|
const resumeCommand = `${command} --resume ${claudeSessionId}`;
|
|
409
475
|
const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { reuseId: sessionId });
|
|
410
|
-
res.status(201).json(
|
|
476
|
+
res.status(201).json(newSnapshot);
|
|
411
477
|
}
|
|
412
478
|
catch (error) {
|
|
413
479
|
res.status(400).json({ error: getErrorMessage(error, "无法恢复会话。") });
|
|
@@ -444,7 +510,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
444
510
|
const newMode = body.mode ? normalizeMode(body.mode, defaultMode) : normalizeMode(existingSession.mode, defaultMode);
|
|
445
511
|
const resumeCommand = `${command} --resume ${claudeSessionId}`;
|
|
446
512
|
const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { reuseId: existingSession.id });
|
|
447
|
-
res.status(201).json({
|
|
513
|
+
res.status(201).json({ resumedClaudeSessionId: claudeSessionId, ...newSnapshot });
|
|
448
514
|
}
|
|
449
515
|
else {
|
|
450
516
|
const cwd = body.cwd?.trim();
|