wave-code 1.1.4 → 1.2.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/dist/bundle/wave.mjs +546 -525
- package/package.json +2 -2
- package/src/commands/plugin/install.ts +5 -5
- package/src/components/ChatInterface.tsx +2 -0
- package/src/components/HooksManager.tsx +286 -0
- package/src/components/InputBox.tsx +86 -49
- package/src/components/MessageBlockItem.tsx +0 -5
- package/src/components/MessageList.tsx +0 -1
- package/src/components/PlanView.tsx +141 -0
- package/src/constants/commands.ts +12 -0
- package/src/contexts/useChat.tsx +94 -70
- package/src/daemon/commands.ts +486 -30
- package/src/hooks/useInputManager.ts +14 -0
- package/src/hooks/useLineScroll.ts +58 -0
- package/src/index.ts +94 -8
- package/src/managers/inputHandlers.ts +2 -0
- package/src/managers/inputReducer.ts +10 -0
- package/src/reducers/hooksManagerReducer.ts +92 -0
- package/src/stdio/agentBridge.ts +552 -55
- package/src/stdio/daemonServer.ts +26 -82
- package/src/stdio/protocol.ts +14 -4
- package/src/stdio-cli.ts +54 -0
- package/src/utils/rewindCheckpoints.ts +2 -2
- package/src/utils/worktree.ts +175 -50
- package/src/components/BangDisplay.tsx +0 -41
package/src/stdio/agentBridge.ts
CHANGED
|
@@ -31,7 +31,10 @@ import {
|
|
|
31
31
|
type QueuedMessage,
|
|
32
32
|
type SessionMetadata,
|
|
33
33
|
type McpServerConfig,
|
|
34
|
+
type McpConfig,
|
|
34
35
|
type Scope,
|
|
36
|
+
type PartialHookConfiguration,
|
|
37
|
+
isValidHookEvent,
|
|
35
38
|
listSessions,
|
|
36
39
|
searchFiles,
|
|
37
40
|
generateRandomName,
|
|
@@ -43,6 +46,9 @@ import {
|
|
|
43
46
|
validateWorktreeRemovalPath,
|
|
44
47
|
type SlashCommand,
|
|
45
48
|
loadUserConfigEnv,
|
|
49
|
+
loadWaveConfigFromFile,
|
|
50
|
+
getUserConfigPaths,
|
|
51
|
+
getProjectConfigPaths,
|
|
46
52
|
type SubagentConfiguration,
|
|
47
53
|
type SkillMetadata,
|
|
48
54
|
} from "wave-agent-sdk";
|
|
@@ -53,10 +59,15 @@ import {
|
|
|
53
59
|
METHOD_NOT_FOUND as PROTOCOL_METHOD_NOT_FOUND,
|
|
54
60
|
} from "./protocol.js";
|
|
55
61
|
import { execFileSync } from "node:child_process";
|
|
56
|
-
import { mkdirSync, existsSync, writeFileSync } from "node:fs";
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
59
|
-
import {
|
|
62
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
63
|
+
import { mkdir, open, readFile, writeFile } from "node:fs/promises";
|
|
64
|
+
import { homedir, tmpdir } from "node:os";
|
|
65
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
66
|
+
import {
|
|
67
|
+
createWorktree,
|
|
68
|
+
getWorktreeChanges,
|
|
69
|
+
removeWorktree,
|
|
70
|
+
} from "../utils/worktree.js";
|
|
60
71
|
import { logger } from "../utils/logger.js";
|
|
61
72
|
import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
|
|
62
73
|
|
|
@@ -68,6 +79,11 @@ export type NotificationEmitter = (
|
|
|
68
79
|
|
|
69
80
|
export interface AgentBridgeOptions {
|
|
70
81
|
emit: NotificationEmitter;
|
|
82
|
+
/** Daemon transport only (`wave daemon stop`/`restart`): invoked by the
|
|
83
|
+
* `shutdown` RPC after every session has been destroyed — the server tears
|
|
84
|
+
* down its socket and exits. stdio mode leaves it unset, so `shutdown` is
|
|
85
|
+
* "Method not found" there. */
|
|
86
|
+
onShutdownRequest?: () => void;
|
|
71
87
|
}
|
|
72
88
|
|
|
73
89
|
interface InitializeParams {
|
|
@@ -88,6 +104,9 @@ interface InitializeParams {
|
|
|
88
104
|
mcpServers?: Record<string, McpServerConfig>;
|
|
89
105
|
worktreeName?: string;
|
|
90
106
|
isNewWorktree?: boolean;
|
|
107
|
+
/** Settings-page auto-memory toggle/frequency (session-level override). */
|
|
108
|
+
autoMemoryEnabled?: boolean;
|
|
109
|
+
autoMemoryFrequency?: number;
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
interface UpdateConfigParams {
|
|
@@ -98,6 +117,9 @@ interface UpdateConfigParams {
|
|
|
98
117
|
model?: string;
|
|
99
118
|
fastModel?: string;
|
|
100
119
|
language?: string;
|
|
120
|
+
/** Settings-page auto-memory toggle/frequency (session-level override). */
|
|
121
|
+
autoMemoryEnabled?: boolean;
|
|
122
|
+
autoMemoryFrequency?: number;
|
|
101
123
|
}
|
|
102
124
|
|
|
103
125
|
interface SearchFilesParams {
|
|
@@ -109,6 +131,16 @@ interface SearchFilesParams {
|
|
|
109
131
|
interface SessionEntry {
|
|
110
132
|
agent: Agent;
|
|
111
133
|
storedConfig: Partial<InitializeParams>;
|
|
134
|
+
/**
|
|
135
|
+
* The workdir the session was CREATED in — an immutable identity, unlike the
|
|
136
|
+
* live `agent.workingDirectory`, which drifts as the session `cd`s (bash tool
|
|
137
|
+
* / worktree switches). Hosted-session consumers (destroy --remove-worktree's
|
|
138
|
+
* worktree resolution, list) read this instead of the live value so a session
|
|
139
|
+
* that moved out of its worktree into the main repo is still cleaned up at
|
|
140
|
+
* the right place. Recovered from the transcript's creation-time metadata
|
|
141
|
+
* header when the session is re-attached from disk after a daemon restart.
|
|
142
|
+
*/
|
|
143
|
+
createdWorkdir: string;
|
|
112
144
|
}
|
|
113
145
|
|
|
114
146
|
/**
|
|
@@ -138,11 +170,13 @@ export class AgentBridge {
|
|
|
138
170
|
>();
|
|
139
171
|
private permissionCounter = 0;
|
|
140
172
|
private emit: NotificationEmitter;
|
|
173
|
+
private onShutdownRequest: (() => void) | undefined;
|
|
141
174
|
private pluginCore: PluginCore | undefined;
|
|
142
175
|
private pluginCoreWorkdir: string | undefined;
|
|
143
176
|
|
|
144
177
|
constructor(options: AgentBridgeOptions) {
|
|
145
178
|
this.emit = options.emit;
|
|
179
|
+
this.onShutdownRequest = options.onShutdownRequest;
|
|
146
180
|
// Mirror the user-level settings env WAVE_SERVER_URL into process.env
|
|
147
181
|
// before any agent initializes. getAuthStatus (webviewReady →
|
|
148
182
|
// pushInitialState) can run before the first agent, and AuthService falls
|
|
@@ -178,6 +212,20 @@ export class AgentBridge {
|
|
|
178
212
|
return this.listPendingPermissions();
|
|
179
213
|
case "listDaemonSessions":
|
|
180
214
|
return this.listDaemonSessions();
|
|
215
|
+
case "shutdown":
|
|
216
|
+
// Daemon-level graceful exit (`wave daemon stop` / `restart`): destroy
|
|
217
|
+
// every session first (each agent saves its transcript and drains
|
|
218
|
+
// auto-memory), then hand the process exit to the transport owner.
|
|
219
|
+
// Only the daemon registers the hook — stdio has no such concept.
|
|
220
|
+
if (!this.onShutdownRequest) {
|
|
221
|
+
throw new RpcError(
|
|
222
|
+
PROTOCOL_METHOD_NOT_FOUND,
|
|
223
|
+
"Method not found: shutdown",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
await this.destroyAll();
|
|
227
|
+
this.onShutdownRequest();
|
|
228
|
+
return null;
|
|
181
229
|
case "updateConfig":
|
|
182
230
|
return this.updateConfig(p as unknown as UpdateConfigParams, sessionId);
|
|
183
231
|
case "getConfiguredModels":
|
|
@@ -228,6 +276,8 @@ export class AgentBridge {
|
|
|
228
276
|
return this.setPermissionMode(p.mode as PermissionMode, sessionId);
|
|
229
277
|
case "getPermissionMode":
|
|
230
278
|
return this.getPermissionMode(sessionId);
|
|
279
|
+
case "getPlanFile":
|
|
280
|
+
return this.getPlanFile(sessionId);
|
|
231
281
|
|
|
232
282
|
// ── MCP ──
|
|
233
283
|
case "getMcpServers":
|
|
@@ -236,6 +286,14 @@ export class AgentBridge {
|
|
|
236
286
|
return this.connectMcpServer(p.serverName as string, sessionId);
|
|
237
287
|
case "disconnectMcpServer":
|
|
238
288
|
return this.disconnectMcpServer(p.serverName as string, sessionId);
|
|
289
|
+
case "removeMcpServer":
|
|
290
|
+
return this.removeMcpServer(
|
|
291
|
+
p.scope as "user" | "project",
|
|
292
|
+
p.serverName as string,
|
|
293
|
+
sessionId,
|
|
294
|
+
);
|
|
295
|
+
case "getMcpConfigPaths":
|
|
296
|
+
return this.getMcpConfigPaths(sessionId);
|
|
239
297
|
|
|
240
298
|
// ── Commands ──
|
|
241
299
|
case "getSlashCommands":
|
|
@@ -244,6 +302,21 @@ export class AgentBridge {
|
|
|
244
302
|
return this.getSubagentConfigurations(sessionId);
|
|
245
303
|
case "getSkillMetadata":
|
|
246
304
|
return this.getSkillMetadata(sessionId);
|
|
305
|
+
case "deleteSkill":
|
|
306
|
+
return this.deleteSkill(p.name as string, sessionId);
|
|
307
|
+
case "deleteSubagent":
|
|
308
|
+
return this.deleteSubagent(p.name as string, sessionId);
|
|
309
|
+
case "getHooksByScope":
|
|
310
|
+
return this.getHooksByScope(
|
|
311
|
+
p.scope as "user" | "project" | "plugin",
|
|
312
|
+
sessionId,
|
|
313
|
+
);
|
|
314
|
+
case "deleteHook":
|
|
315
|
+
return this.deleteHook(
|
|
316
|
+
p.scope as "user" | "project",
|
|
317
|
+
p.hookName as string,
|
|
318
|
+
sessionId,
|
|
319
|
+
);
|
|
247
320
|
|
|
248
321
|
// ── File / History (global — no session required) ──
|
|
249
322
|
case "searchFiles":
|
|
@@ -267,11 +340,29 @@ export class AgentBridge {
|
|
|
267
340
|
// ── Auth (global — no session required) ──
|
|
268
341
|
case "getAuthStatus":
|
|
269
342
|
return this.getAuthStatus();
|
|
343
|
+
case "getAccountInfo":
|
|
344
|
+
return this.getAccountInfo();
|
|
270
345
|
case "login":
|
|
271
346
|
return this.login(p.serverUrl as string | undefined);
|
|
272
347
|
case "logout":
|
|
273
348
|
return this.logout();
|
|
274
349
|
|
|
350
|
+
// ── Memory files (settings UI — user-level ~/.wave/AGENTS.md and
|
|
351
|
+
// project-level <workdir>/AGENTS.md) ──
|
|
352
|
+
case "getAgentsContent":
|
|
353
|
+
return this.getAgentsContent(
|
|
354
|
+
p.scope as "user" | "project",
|
|
355
|
+
p.workdir as string | undefined,
|
|
356
|
+
sessionId,
|
|
357
|
+
);
|
|
358
|
+
case "setAgentsContent":
|
|
359
|
+
return this.setAgentsContent(
|
|
360
|
+
p.scope as "user" | "project",
|
|
361
|
+
p.content as string,
|
|
362
|
+
p.workdir as string | undefined,
|
|
363
|
+
sessionId,
|
|
364
|
+
);
|
|
365
|
+
|
|
275
366
|
// ── Plugins (global — no session required) ──
|
|
276
367
|
case "listPlugins":
|
|
277
368
|
return this.listPlugins(p.workdir as string | undefined, sessionId);
|
|
@@ -307,6 +398,18 @@ export class AgentBridge {
|
|
|
307
398
|
p.workdir as string | undefined,
|
|
308
399
|
sessionId,
|
|
309
400
|
);
|
|
401
|
+
case "getHooksConfig":
|
|
402
|
+
return this.getHooksConfig(
|
|
403
|
+
p.scope as "user" | "project" | undefined,
|
|
404
|
+
p.workdir as string | undefined,
|
|
405
|
+
sessionId,
|
|
406
|
+
);
|
|
407
|
+
case "getMcpConfig":
|
|
408
|
+
return this.getMcpConfig(
|
|
409
|
+
p.scope as "user" | "project" | undefined,
|
|
410
|
+
p.workdir as string | undefined,
|
|
411
|
+
sessionId,
|
|
412
|
+
);
|
|
310
413
|
case "setBuiltinPluginEnabled":
|
|
311
414
|
return this.setBuiltinPluginEnabled(
|
|
312
415
|
p.pluginId as string,
|
|
@@ -376,6 +479,10 @@ export class AgentBridge {
|
|
|
376
479
|
name?: string;
|
|
377
480
|
},
|
|
378
481
|
);
|
|
482
|
+
case "getWorktreeChanges":
|
|
483
|
+
return this.getWorktreeChanges(
|
|
484
|
+
p as unknown as { path?: string; baseBranch?: string },
|
|
485
|
+
);
|
|
379
486
|
case "removeWorktree":
|
|
380
487
|
return this.removeWorktreeSession(
|
|
381
488
|
p as unknown as {
|
|
@@ -446,6 +553,8 @@ export class AgentBridge {
|
|
|
446
553
|
model: params.model,
|
|
447
554
|
fastModel: params.fastModel,
|
|
448
555
|
language: params.language,
|
|
556
|
+
autoMemoryEnabled: params.autoMemoryEnabled,
|
|
557
|
+
autoMemoryFrequency: params.autoMemoryFrequency,
|
|
449
558
|
permissionMode: params.permissionMode,
|
|
450
559
|
tools: params.tools,
|
|
451
560
|
allowedTools: params.allowedTools,
|
|
@@ -462,9 +571,29 @@ export class AgentBridge {
|
|
|
462
571
|
ctx.agent = agent;
|
|
463
572
|
ctx.registeredSessionId = agent.sessionId;
|
|
464
573
|
|
|
574
|
+
// Record where the session was created (see SessionEntry.createdWorkdir).
|
|
575
|
+
// A fresh session's initialize workdir IS the creation dir. A from-disk
|
|
576
|
+
// re-attach (the daemon was restarted — killed / CLI 升级重启 / reboot —
|
|
577
|
+
// then status/send re-hosts the session) instead passes the re-attaching
|
|
578
|
+
// client's cwd — the transcript header (`{"type":"metadata","workdir":...}`, append-only since session
|
|
579
|
+
// creation) restores the original value when the agent's transcript is
|
|
580
|
+
// readable; otherwise the client cwd is the best available anchor.
|
|
581
|
+
let createdWorkdir: string;
|
|
582
|
+
if (params.restoreSessionId) {
|
|
583
|
+
createdWorkdir =
|
|
584
|
+
(await readTranscriptWorkdir(agent.sessionFilePath)) ??
|
|
585
|
+
params.workdir ??
|
|
586
|
+
agent.workingDirectory;
|
|
587
|
+
} else {
|
|
588
|
+
createdWorkdir = params.workdir ?? agent.workingDirectory;
|
|
589
|
+
}
|
|
590
|
+
|
|
465
591
|
this.sessions.set(agent.sessionId, {
|
|
466
592
|
agent,
|
|
467
|
-
storedConfig:
|
|
593
|
+
// Keep storedConfig.workdir in sync: updateConfig rebuilds the agent from
|
|
594
|
+
// storedConfig, so the rebuild must stay anchored on the creation dir too.
|
|
595
|
+
storedConfig: { ...params, workdir: createdWorkdir },
|
|
596
|
+
createdWorkdir,
|
|
468
597
|
});
|
|
469
598
|
|
|
470
599
|
return {
|
|
@@ -486,27 +615,6 @@ export class AgentBridge {
|
|
|
486
615
|
return null;
|
|
487
616
|
}
|
|
488
617
|
|
|
489
|
-
/**
|
|
490
|
-
* True when every hosted session has settled: not generating, nothing queued,
|
|
491
|
-
* and no background work (background bash / subagents / workflows) — the same
|
|
492
|
-
* condition `wave -p` waits on before exiting (print-cli.ts). Pending
|
|
493
|
-
* permission approvals keep the owning agent's isLoading true, so they are
|
|
494
|
-
* covered without an explicit check.
|
|
495
|
-
*/
|
|
496
|
-
public isIdle(): boolean {
|
|
497
|
-
for (const entry of this.sessions.values()) {
|
|
498
|
-
const agent = entry.agent;
|
|
499
|
-
if (
|
|
500
|
-
agent.isLoading ||
|
|
501
|
-
agent.hasPendingMessages ||
|
|
502
|
-
agent.hasRunningBackgroundWork
|
|
503
|
-
) {
|
|
504
|
-
return false;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
return true;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
618
|
/**
|
|
511
619
|
* Destroy every hosted session agent. Each Agent.destroy() saves its
|
|
512
620
|
* transcript, drains in-flight auto-memory extraction, and cleans up
|
|
@@ -533,7 +641,7 @@ export class AgentBridge {
|
|
|
533
641
|
if (entry.agent.sessionId === restoreId) {
|
|
534
642
|
this.emit(
|
|
535
643
|
"messagesChange",
|
|
536
|
-
{ messages: entry.agent.
|
|
644
|
+
{ messages: entry.agent.displayMessages },
|
|
537
645
|
entry.agent.sessionId,
|
|
538
646
|
);
|
|
539
647
|
// The re-attached client also missed the loading state that settled
|
|
@@ -548,12 +656,57 @@ export class AgentBridge {
|
|
|
548
656
|
},
|
|
549
657
|
entry.agent.sessionId,
|
|
550
658
|
);
|
|
659
|
+
// Same story for the context-usage indicator: the SDK's restore-time
|
|
660
|
+
// onLatestTotalTokensChange fired inside initialize — before this
|
|
661
|
+
// client's router registered — so replay the current usage or the
|
|
662
|
+
// webview keeps the previous session's (or no) percentage until the
|
|
663
|
+
// next turn pushes a fresh value (spec desktop-app 上下文用量指示器
|
|
664
|
+
// 场景 6: 恢复即显示该会话上次用量).
|
|
665
|
+
this.emitContextUsage(entry.agent);
|
|
551
666
|
return null;
|
|
552
667
|
}
|
|
553
668
|
await entry.agent.restoreSession(restoreId);
|
|
669
|
+
// A real restore must also re-emit the usage unconditionally: the SDK
|
|
670
|
+
// only fires onLatestTotalTokensChange on a VALUE CHANGE, so restoring a
|
|
671
|
+
// conversation whose persisted total equals the agent's current one
|
|
672
|
+
// suppresses the change-push and the webview — which cleared the previous
|
|
673
|
+
// session's number on the switch — keeps a blank ring until the next turn
|
|
674
|
+
// (the change-push, when it does fire inside restoreSession, already
|
|
675
|
+
// delivered the same value; re-emitting is idempotent).
|
|
676
|
+
this.emitContextUsage(entry.agent);
|
|
554
677
|
return null;
|
|
555
678
|
}
|
|
556
679
|
|
|
680
|
+
/**
|
|
681
|
+
* Push the session's current context-usage percentage to the client. Shared
|
|
682
|
+
* by every restore path so a conversation the client just switched to always
|
|
683
|
+
* shows its usage right away instead of waiting for the next token change
|
|
684
|
+
* (spec desktop-app 上下文用量指示器 场景 6). A zero total means no real
|
|
685
|
+
* usage to report; the host keeps the empty ring until the first push.
|
|
686
|
+
*/
|
|
687
|
+
private emitContextUsage(agent: Agent): void {
|
|
688
|
+
const percent = this.contextUsagePercentOf(agent);
|
|
689
|
+
if (percent !== undefined) {
|
|
690
|
+
this.emit("contextUsage", { percent }, agent.sessionId);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Current context-usage percentage of a session, or undefined when there is
|
|
696
|
+
* nothing to report yet (no tokens) or the model exposes no limit. Shared by
|
|
697
|
+
* the change-driven push and the getMessages response, so a host whose
|
|
698
|
+
* webview was re-created long after the last token change can still restore
|
|
699
|
+
* the ring without a per-host cache.
|
|
700
|
+
*/
|
|
701
|
+
private contextUsagePercentOf(agent: Agent): number | undefined {
|
|
702
|
+
const tokens = agent.latestTotalTokens;
|
|
703
|
+
const max = agent.getMaxInputTokens();
|
|
704
|
+
if (tokens > 0 && max > 0) {
|
|
705
|
+
return Math.min(100, Math.round((tokens / max) * 100));
|
|
706
|
+
}
|
|
707
|
+
return undefined;
|
|
708
|
+
}
|
|
709
|
+
|
|
557
710
|
private async listSessions(
|
|
558
711
|
workdir?: string,
|
|
559
712
|
sessionId?: string,
|
|
@@ -647,6 +800,21 @@ export class AgentBridge {
|
|
|
647
800
|
}
|
|
648
801
|
}
|
|
649
802
|
|
|
803
|
+
/**
|
|
804
|
+
* What a worktree deletion would throw away (uncommitted files and commits
|
|
805
|
+
* not on the base branch), so the caller can warn before deleting. Returns
|
|
806
|
+
* null when the worktree cannot be inspected.
|
|
807
|
+
*/
|
|
808
|
+
private async getWorktreeChanges(params: {
|
|
809
|
+
path?: string;
|
|
810
|
+
baseBranch?: string;
|
|
811
|
+
}): Promise<{ files: number; commits: number } | null> {
|
|
812
|
+
if (!params.path) {
|
|
813
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, "path is required");
|
|
814
|
+
}
|
|
815
|
+
return getWorktreeChanges(params.path, params.baseBranch);
|
|
816
|
+
}
|
|
817
|
+
|
|
650
818
|
private async removeWorktreeSession(params: {
|
|
651
819
|
path: string;
|
|
652
820
|
branch: string;
|
|
@@ -692,7 +860,10 @@ export class AgentBridge {
|
|
|
692
860
|
const entry = this.requireSession(sessionId);
|
|
693
861
|
return {
|
|
694
862
|
sessionId: entry.agent.sessionId,
|
|
695
|
-
|
|
863
|
+
// The recorded creation workdir, not the live agent.workingDirectory:
|
|
864
|
+
// destroy --remove-worktree resolves the session's worktree from this
|
|
865
|
+
// value, and it must not drift when the session cd'd out of the worktree.
|
|
866
|
+
workingDirectory: entry.createdWorkdir,
|
|
696
867
|
latestTotalTokens: entry.agent.latestTotalTokens,
|
|
697
868
|
permissionMode: entry.agent.getPermissionMode(),
|
|
698
869
|
availableTools: entry.agent.getAvailableToolNames(),
|
|
@@ -723,6 +894,8 @@ export class AgentBridge {
|
|
|
723
894
|
model: entry.storedConfig.model,
|
|
724
895
|
fastModel: entry.storedConfig.fastModel,
|
|
725
896
|
language: entry.storedConfig.language,
|
|
897
|
+
autoMemoryEnabled: entry.storedConfig.autoMemoryEnabled,
|
|
898
|
+
autoMemoryFrequency: entry.storedConfig.autoMemoryFrequency,
|
|
726
899
|
permissionMode: entry.storedConfig.permissionMode,
|
|
727
900
|
tools: entry.storedConfig.tools,
|
|
728
901
|
allowedTools: entry.storedConfig.allowedTools,
|
|
@@ -764,6 +937,7 @@ export class AgentBridge {
|
|
|
764
937
|
this.sessions.set(agent.sessionId, {
|
|
765
938
|
agent,
|
|
766
939
|
storedConfig: { ...entry.storedConfig },
|
|
940
|
+
createdWorkdir: entry.createdWorkdir,
|
|
767
941
|
});
|
|
768
942
|
|
|
769
943
|
return { sessionId: agent.sessionId };
|
|
@@ -973,9 +1147,15 @@ export class AgentBridge {
|
|
|
973
1147
|
return null;
|
|
974
1148
|
}
|
|
975
1149
|
|
|
976
|
-
private getMessages(sessionId?: string): {
|
|
1150
|
+
private getMessages(sessionId?: string): {
|
|
1151
|
+
messages: Message[];
|
|
1152
|
+
contextUsagePercent?: number;
|
|
1153
|
+
} {
|
|
977
1154
|
const entry = this.requireSession(sessionId);
|
|
978
|
-
return {
|
|
1155
|
+
return {
|
|
1156
|
+
messages: entry.agent.displayMessages,
|
|
1157
|
+
contextUsagePercent: this.contextUsagePercentOf(entry.agent),
|
|
1158
|
+
};
|
|
979
1159
|
}
|
|
980
1160
|
|
|
981
1161
|
private async getFullMessageThread(sessionId?: string): Promise<{
|
|
@@ -1059,6 +1239,32 @@ export class AgentBridge {
|
|
|
1059
1239
|
return { mode: entry.agent.getPermissionMode() };
|
|
1060
1240
|
}
|
|
1061
1241
|
|
|
1242
|
+
/**
|
|
1243
|
+
* Returns the current plan file path and its contents for the session.
|
|
1244
|
+
* When the path generation is still in flight (e.g. the host just switched
|
|
1245
|
+
* to plan mode via setPermissionMode), awaits it so the caller can read the
|
|
1246
|
+
* plan before triggering a query (the plan mode reminder needs the path).
|
|
1247
|
+
*/
|
|
1248
|
+
private async getPlanFile(sessionId?: string): Promise<{
|
|
1249
|
+
path: string | null;
|
|
1250
|
+
content: string | null;
|
|
1251
|
+
}> {
|
|
1252
|
+
const entry = this.requireSession(sessionId);
|
|
1253
|
+
const agent = entry.agent;
|
|
1254
|
+
const planPath =
|
|
1255
|
+
agent.getPlanFilePath() ?? (await agent.awaitPlanFilePath());
|
|
1256
|
+
if (!planPath) {
|
|
1257
|
+
return { path: null, content: null };
|
|
1258
|
+
}
|
|
1259
|
+
try {
|
|
1260
|
+
const content = await readFile(planPath, "utf8");
|
|
1261
|
+
return { path: planPath, content };
|
|
1262
|
+
} catch (error) {
|
|
1263
|
+
logger?.warn("Failed to read plan file", error);
|
|
1264
|
+
return { path: planPath, content: null };
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1062
1268
|
// ── MCP ───────────────────────────────────────────────────────
|
|
1063
1269
|
|
|
1064
1270
|
private getMcpServers(sessionId?: string): { servers: McpServerStatus[] } {
|
|
@@ -1084,6 +1290,27 @@ export class AgentBridge {
|
|
|
1084
1290
|
return { success };
|
|
1085
1291
|
}
|
|
1086
1292
|
|
|
1293
|
+
private async removeMcpServer(
|
|
1294
|
+
scope: "user" | "project",
|
|
1295
|
+
serverName: string,
|
|
1296
|
+
sessionId?: string,
|
|
1297
|
+
): Promise<{ success: boolean }> {
|
|
1298
|
+
const entry = this.requireSession(sessionId);
|
|
1299
|
+
const success = await entry.agent.removeMcpServer(scope, serverName);
|
|
1300
|
+
return { success };
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
private getMcpConfigPaths(sessionId?: string): {
|
|
1304
|
+
userPath: string | null;
|
|
1305
|
+
projectPath: string | null;
|
|
1306
|
+
} {
|
|
1307
|
+
const entry = this.requireSession(sessionId);
|
|
1308
|
+
return {
|
|
1309
|
+
userPath: entry.agent.getUserMcpConfigPath(),
|
|
1310
|
+
projectPath: entry.agent.getProjectMcpConfigPath(),
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1087
1314
|
// ── Commands ──────────────────────────────────────────────────
|
|
1088
1315
|
|
|
1089
1316
|
private getSlashCommands(sessionId?: string): { commands: SlashCommand[] } {
|
|
@@ -1103,6 +1330,50 @@ export class AgentBridge {
|
|
|
1103
1330
|
return { skills: entry.agent.getSkillMetadata() };
|
|
1104
1331
|
}
|
|
1105
1332
|
|
|
1333
|
+
private async deleteSkill(
|
|
1334
|
+
name: string,
|
|
1335
|
+
sessionId?: string,
|
|
1336
|
+
): Promise<{ success: boolean }> {
|
|
1337
|
+
const entry = this.requireSession(sessionId);
|
|
1338
|
+
const success = await entry.agent.deleteSkill(name);
|
|
1339
|
+
return { success };
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
private async deleteSubagent(
|
|
1343
|
+
name: string,
|
|
1344
|
+
sessionId?: string,
|
|
1345
|
+
): Promise<{ success: boolean }> {
|
|
1346
|
+
const entry = this.requireSession(sessionId);
|
|
1347
|
+
const success = await entry.agent.deleteSubagent(name);
|
|
1348
|
+
return { success };
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
private async getHooksByScope(
|
|
1352
|
+
scope: "user" | "project" | "plugin",
|
|
1353
|
+
sessionId?: string,
|
|
1354
|
+
): Promise<{
|
|
1355
|
+
hooks: Partial<Record<string, unknown[]>>;
|
|
1356
|
+
configPath: string | null;
|
|
1357
|
+
}> {
|
|
1358
|
+
const entry = this.requireSession(sessionId);
|
|
1359
|
+
const hooks = await entry.agent.getHooksByScope(scope);
|
|
1360
|
+
// 回带该 scope 钩子所在 settings.json 的绝对路径(宿主 GUI 打开文件用;
|
|
1361
|
+
// plugin 钩子来自代码而非配置文件 → null)。
|
|
1362
|
+
const configPath =
|
|
1363
|
+
scope === "plugin" ? null : entry.agent.getHookConfigPath(scope);
|
|
1364
|
+
return { hooks, configPath };
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
private async deleteHook(
|
|
1368
|
+
scope: "user" | "project",
|
|
1369
|
+
hookName: string,
|
|
1370
|
+
sessionId?: string,
|
|
1371
|
+
): Promise<{ success: boolean }> {
|
|
1372
|
+
const entry = this.requireSession(sessionId);
|
|
1373
|
+
await entry.agent.deleteHook(scope, hookName);
|
|
1374
|
+
return { success: true };
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1106
1377
|
// ── File / History (global) ───────────────────────────────────
|
|
1107
1378
|
|
|
1108
1379
|
private async searchFiles(
|
|
@@ -1219,7 +1490,9 @@ export class AgentBridge {
|
|
|
1219
1490
|
}
|
|
1220
1491
|
|
|
1221
1492
|
/** Daemon list: expose the in-memory session registry (live sessions only,
|
|
1222
|
-
* not disk-scanning). Registration order is preserved.
|
|
1493
|
+
* not disk-scanning). Registration order is preserved. The working directory
|
|
1494
|
+
* shown is the session's recorded creation workdir (stable identity), not the
|
|
1495
|
+
* live value that drifts with in-session `cd`. */
|
|
1223
1496
|
private listDaemonSessions(): {
|
|
1224
1497
|
sessions: Array<{
|
|
1225
1498
|
sessionId: string;
|
|
@@ -1231,9 +1504,9 @@ export class AgentBridge {
|
|
|
1231
1504
|
return {
|
|
1232
1505
|
sessions: [...this.sessions.entries()].map(([sessionId, entry]) => ({
|
|
1233
1506
|
sessionId,
|
|
1234
|
-
workingDirectory: entry.
|
|
1507
|
+
workingDirectory: entry.createdWorkdir,
|
|
1235
1508
|
isLoading: entry.agent.isLoading,
|
|
1236
|
-
messageCount: entry.agent.
|
|
1509
|
+
messageCount: entry.agent.displayMessages.length,
|
|
1237
1510
|
})),
|
|
1238
1511
|
};
|
|
1239
1512
|
}
|
|
@@ -1254,6 +1527,31 @@ export class AgentBridge {
|
|
|
1254
1527
|
};
|
|
1255
1528
|
}
|
|
1256
1529
|
|
|
1530
|
+
private async getAccountInfo(): Promise<{
|
|
1531
|
+
plan?: { monthlyQuota: number; months: number; used: number } | null;
|
|
1532
|
+
apiQuota?: { limit: number | null; used: number } | null;
|
|
1533
|
+
}> {
|
|
1534
|
+
const authService = AuthService.getInstance();
|
|
1535
|
+
await authService.checkAndRefreshTokenIfNeeded();
|
|
1536
|
+
if (!authService.isSSOAuthenticated()) return {};
|
|
1537
|
+
const token = authService.getSSOToken();
|
|
1538
|
+
if (!token) return {};
|
|
1539
|
+
const response = await fetch(
|
|
1540
|
+
`${authService.getServerUrl()}/api/v1/account`,
|
|
1541
|
+
{
|
|
1542
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1543
|
+
},
|
|
1544
|
+
);
|
|
1545
|
+
if (!response.ok) {
|
|
1546
|
+
// 401 / 5xx — 让调用方(desktop host)保留上次成功数据(spec 场景 9)。
|
|
1547
|
+
throw new Error(`Account query failed (${response.status})`);
|
|
1548
|
+
}
|
|
1549
|
+
return (await response.json()) as {
|
|
1550
|
+
plan?: { monthlyQuota: number; months: number; used: number } | null;
|
|
1551
|
+
apiQuota?: { limit: number | null; used: number } | null;
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1257
1555
|
private async login(
|
|
1258
1556
|
serverUrl?: string,
|
|
1259
1557
|
): Promise<{ user: { id: string; email?: string } | undefined }> {
|
|
@@ -1273,6 +1571,88 @@ export class AgentBridge {
|
|
|
1273
1571
|
return null;
|
|
1274
1572
|
}
|
|
1275
1573
|
|
|
1574
|
+
// ── Memory files (settings UI) ───────────────────────────────
|
|
1575
|
+
|
|
1576
|
+
/**
|
|
1577
|
+
* Read an AGENTS.md file for the settings UI: user-level reads
|
|
1578
|
+
* ~/.wave/AGENTS.md, project-level reads <workdir>/AGENTS.md. Prefers the
|
|
1579
|
+
* live agent (clears its caches on write) and falls back to direct file
|
|
1580
|
+
* access when no session is bound (e.g. desktop before any session exists).
|
|
1581
|
+
*/
|
|
1582
|
+
private async getAgentsContent(
|
|
1583
|
+
scope: "user" | "project",
|
|
1584
|
+
workdir?: string,
|
|
1585
|
+
sessionId?: string,
|
|
1586
|
+
): Promise<{ content: string; path?: string }> {
|
|
1587
|
+
const agent = this.sessions.get(sessionId ?? "")?.agent;
|
|
1588
|
+
if (scope === "user") {
|
|
1589
|
+
if (agent) {
|
|
1590
|
+
return { content: await agent.readUserMemoryContent() };
|
|
1591
|
+
}
|
|
1592
|
+
const path = this.getUserMemoryFilePath();
|
|
1593
|
+
return { content: await readTextFileSafe(path), path };
|
|
1594
|
+
}
|
|
1595
|
+
const resolvedWorkdir =
|
|
1596
|
+
workdir || (agent?.workingDirectory as string | undefined);
|
|
1597
|
+
if (!resolvedWorkdir) {
|
|
1598
|
+
throw new RpcError(
|
|
1599
|
+
PROTOCOL_INVALID_PARAMS,
|
|
1600
|
+
"Project-scope AGENTS.md requires a workdir",
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
if (agent) {
|
|
1604
|
+
return {
|
|
1605
|
+
content: await agent.readProjectMemoryContent(resolvedWorkdir),
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
const path = join(resolvedWorkdir, "AGENTS.md");
|
|
1609
|
+
return { content: await readTextFileSafe(path), path };
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* Write an AGENTS.md file from the settings UI: user-level writes
|
|
1614
|
+
* ~/.wave/AGENTS.md, project-level writes <workdir>/AGENTS.md. Goes through
|
|
1615
|
+
* the live agent when available so its memory caches are invalidated.
|
|
1616
|
+
*/
|
|
1617
|
+
private async setAgentsContent(
|
|
1618
|
+
scope: "user" | "project",
|
|
1619
|
+
content: string,
|
|
1620
|
+
workdir?: string,
|
|
1621
|
+
sessionId?: string,
|
|
1622
|
+
): Promise<{ ok: boolean; path?: string }> {
|
|
1623
|
+
const agent = this.sessions.get(sessionId ?? "")?.agent;
|
|
1624
|
+
if (scope === "user") {
|
|
1625
|
+
if (agent) {
|
|
1626
|
+
await agent.writeUserMemoryContent(content);
|
|
1627
|
+
} else {
|
|
1628
|
+
const path = this.getUserMemoryFilePath();
|
|
1629
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1630
|
+
await writeFile(path, content, "utf-8");
|
|
1631
|
+
}
|
|
1632
|
+
return { ok: true };
|
|
1633
|
+
}
|
|
1634
|
+
const resolvedWorkdir =
|
|
1635
|
+
workdir || (agent?.workingDirectory as string | undefined);
|
|
1636
|
+
if (!resolvedWorkdir) {
|
|
1637
|
+
throw new RpcError(
|
|
1638
|
+
PROTOCOL_INVALID_PARAMS,
|
|
1639
|
+
"Project-scope AGENTS.md requires a workdir",
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
if (agent) {
|
|
1643
|
+
await agent.writeProjectMemoryContent(resolvedWorkdir, content);
|
|
1644
|
+
} else {
|
|
1645
|
+
const path = join(resolvedWorkdir, "AGENTS.md");
|
|
1646
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1647
|
+
await writeFile(path, content, "utf-8");
|
|
1648
|
+
}
|
|
1649
|
+
return { ok: true };
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
private getUserMemoryFilePath(): string {
|
|
1653
|
+
return join(homedir(), ".wave", "AGENTS.md");
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1276
1656
|
// ── Plugins (global) ─────────────────────────────────────────
|
|
1277
1657
|
|
|
1278
1658
|
private getPluginCore(workdir?: string, sessionId?: string): PluginCore {
|
|
@@ -1359,6 +1739,81 @@ export class AgentBridge {
|
|
|
1359
1739
|
};
|
|
1360
1740
|
}
|
|
1361
1741
|
|
|
1742
|
+
/**
|
|
1743
|
+
* Read the hooks fragment from a single config scope (user or project), for
|
|
1744
|
+
* the settings page read-only hooks view. Hooks come from settings.json
|
|
1745
|
+
* (user: ~/.wave/settings.json; project: .wave/settings.json +
|
|
1746
|
+
* settings.local.json merged, append per event).
|
|
1747
|
+
*/
|
|
1748
|
+
private async getHooksConfig(
|
|
1749
|
+
scope: "user" | "project" | undefined,
|
|
1750
|
+
workdir?: string,
|
|
1751
|
+
sessionId?: string,
|
|
1752
|
+
): Promise<{ hooks: PartialHookConfiguration | undefined }> {
|
|
1753
|
+
const resolvedScope = scope ?? "user";
|
|
1754
|
+
|
|
1755
|
+
if (resolvedScope === "user") {
|
|
1756
|
+
const config = loadWaveConfigFromFile(getUserConfigPaths()[0]);
|
|
1757
|
+
return { hooks: config?.hooks };
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
const resolvedWorkdir =
|
|
1761
|
+
workdir || this.getSessionWorkdir(sessionId) || process.cwd();
|
|
1762
|
+
const [localPath, jsonPath] = getProjectConfigPaths(resolvedWorkdir);
|
|
1763
|
+
const hooks: PartialHookConfiguration = {};
|
|
1764
|
+
for (const config of [
|
|
1765
|
+
loadWaveConfigFromFile(jsonPath),
|
|
1766
|
+
loadWaveConfigFromFile(localPath),
|
|
1767
|
+
]) {
|
|
1768
|
+
if (!config?.hooks) continue;
|
|
1769
|
+
for (const [event, eventConfigs] of Object.entries(config.hooks)) {
|
|
1770
|
+
if (!isValidHookEvent(event)) continue;
|
|
1771
|
+
hooks[event] = [...(hooks[event] ?? []), ...(eventConfigs ?? [])];
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
return { hooks: Object.keys(hooks).length > 0 ? hooks : undefined };
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
/**
|
|
1778
|
+
* Read the mcpServers fragment from a single config scope (user or project),
|
|
1779
|
+
* for the settings page read-only MCP view. MCP servers come from mcp.json
|
|
1780
|
+
* (user: ~/.wave/mcp.json; project: <workdir>/.mcp.json). Runtime status
|
|
1781
|
+
* (connect/disconnect/tools) is served by the existing getMcpServers RPC.
|
|
1782
|
+
*/
|
|
1783
|
+
private async getMcpConfig(
|
|
1784
|
+
scope: "user" | "project" | undefined,
|
|
1785
|
+
workdir?: string,
|
|
1786
|
+
sessionId?: string,
|
|
1787
|
+
): Promise<{ mcpServers: Record<string, McpServerConfig> }> {
|
|
1788
|
+
const resolvedScope = scope ?? "user";
|
|
1789
|
+
|
|
1790
|
+
if (resolvedScope === "user") {
|
|
1791
|
+
return {
|
|
1792
|
+
mcpServers: this.readMcpServersFile(
|
|
1793
|
+
join(homedir(), ".wave", "mcp.json"),
|
|
1794
|
+
),
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
const resolvedWorkdir =
|
|
1799
|
+
workdir || this.getSessionWorkdir(sessionId) || process.cwd();
|
|
1800
|
+
return {
|
|
1801
|
+
mcpServers: this.readMcpServersFile(join(resolvedWorkdir, ".mcp.json")),
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
private readMcpServersFile(
|
|
1806
|
+
filePath: string,
|
|
1807
|
+
): Record<string, McpServerConfig> {
|
|
1808
|
+
try {
|
|
1809
|
+
if (!existsSync(filePath)) return {};
|
|
1810
|
+
const raw = JSON.parse(readFileSync(filePath, "utf-8")) as McpConfig;
|
|
1811
|
+
return raw.mcpServers ?? {};
|
|
1812
|
+
} catch {
|
|
1813
|
+
return {};
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1362
1817
|
private async setBuiltinPluginEnabled(
|
|
1363
1818
|
pluginId: string,
|
|
1364
1819
|
enabled: boolean,
|
|
@@ -1483,6 +1938,18 @@ export class AgentBridge {
|
|
|
1483
1938
|
ctx.registeredSessionId,
|
|
1484
1939
|
);
|
|
1485
1940
|
},
|
|
1941
|
+
onLatestTotalTokensChange: (tokens: number) => {
|
|
1942
|
+
// Batch 2 压缩上下文 button: push the context usage percentage on
|
|
1943
|
+
// every token-count change. The SDK fires this on each finished
|
|
1944
|
+
// request (fresh total) and on session restore (the persisted total),
|
|
1945
|
+
// so the webview button shows real usage without waiting for a turn —
|
|
1946
|
+
// aligned with the CLI, which reads the same callback (spec 场景 5/6).
|
|
1947
|
+
const max = ctx.agent?.getMaxInputTokens() ?? 0;
|
|
1948
|
+
if (max > 0) {
|
|
1949
|
+
const percent = Math.min(100, Math.round((tokens / max) * 100));
|
|
1950
|
+
this.emit("contextUsage", { percent }, ctx.registeredSessionId);
|
|
1951
|
+
}
|
|
1952
|
+
},
|
|
1486
1953
|
onCommandRunningChange: (running: boolean) => {
|
|
1487
1954
|
this.emit("commandRunningChange", { running }, ctx.registeredSessionId);
|
|
1488
1955
|
},
|
|
@@ -1542,29 +2009,8 @@ export class AgentBridge {
|
|
|
1542
2009
|
onMcpServersChange: (servers: McpServerStatus[]) => {
|
|
1543
2010
|
this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
|
|
1544
2011
|
},
|
|
1545
|
-
onAddBangMessage: (command, messageId) => {
|
|
1546
|
-
this.emit(
|
|
1547
|
-
"bangMessageAdded",
|
|
1548
|
-
{ command, messageId },
|
|
1549
|
-
ctx.registeredSessionId,
|
|
1550
|
-
);
|
|
1551
|
-
},
|
|
1552
|
-
onUpdateBangMessage: (command, output, messageId) => {
|
|
1553
|
-
this.emit(
|
|
1554
|
-
"bangMessageUpdated",
|
|
1555
|
-
{ command, output, messageId },
|
|
1556
|
-
ctx.registeredSessionId,
|
|
1557
|
-
);
|
|
1558
|
-
},
|
|
1559
|
-
onCompleteBangMessage: (command, exitCode, messageId, output) => {
|
|
1560
|
-
this.emit(
|
|
1561
|
-
"bangMessageCompleted",
|
|
1562
|
-
{ command, exitCode, messageId, output },
|
|
1563
|
-
ctx.registeredSessionId,
|
|
1564
|
-
);
|
|
1565
|
-
},
|
|
1566
2012
|
onNotificationMessageAdded: (params) => {
|
|
1567
|
-
const msg = ctx.agent?.
|
|
2013
|
+
const msg = ctx.agent?.displayMessages.find(
|
|
1568
2014
|
(m) =>
|
|
1569
2015
|
m.role === "user" &&
|
|
1570
2016
|
m.blocks.some(
|
|
@@ -1656,3 +2102,54 @@ function isSessionRecoveryError(error: unknown): boolean {
|
|
|
1656
2102
|
message.startsWith("Session not found:")
|
|
1657
2103
|
);
|
|
1658
2104
|
}
|
|
2105
|
+
|
|
2106
|
+
/** Read a text file, returning "" when it does not exist. */
|
|
2107
|
+
async function readTextFileSafe(filePath: string): Promise<string> {
|
|
2108
|
+
try {
|
|
2109
|
+
return await readFile(filePath, "utf-8");
|
|
2110
|
+
} catch (error) {
|
|
2111
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
2112
|
+
return "";
|
|
2113
|
+
}
|
|
2114
|
+
throw error;
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
/**
|
|
2119
|
+
* Read the creation-time workdir from a session transcript's metadata header —
|
|
2120
|
+
* the first line `{"type":"metadata","workdir":...,"createdAt":...,"gitBranch":...}`
|
|
2121
|
+
* the SDK writes once at session creation. The header is append-only, so it
|
|
2122
|
+
* records where the session was created even after the live working directory
|
|
2123
|
+
* drifted (in-session `cd`) or after a daemon restart re-anchored the agent at
|
|
2124
|
+
* a client's cwd. Returns undefined when the file is missing, legacy (no
|
|
2125
|
+
* header), or unreadable. Only the header line is read (transcripts can grow
|
|
2126
|
+
* large); a 4KiB read covers any realistic header.
|
|
2127
|
+
*/
|
|
2128
|
+
async function readTranscriptWorkdir(
|
|
2129
|
+
transcriptPath?: string,
|
|
2130
|
+
): Promise<string | undefined> {
|
|
2131
|
+
if (!transcriptPath) return undefined;
|
|
2132
|
+
try {
|
|
2133
|
+
const handle = await open(transcriptPath, "r");
|
|
2134
|
+
try {
|
|
2135
|
+
const buffer = Buffer.alloc(4096);
|
|
2136
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
2137
|
+
if (bytesRead === 0) return undefined;
|
|
2138
|
+
const newline = buffer.indexOf(0x0a);
|
|
2139
|
+
const firstLine = buffer
|
|
2140
|
+
.subarray(0, newline === -1 ? bytesRead : newline)
|
|
2141
|
+
.toString("utf8");
|
|
2142
|
+
const header = JSON.parse(firstLine) as {
|
|
2143
|
+
type?: string;
|
|
2144
|
+
workdir?: unknown;
|
|
2145
|
+
};
|
|
2146
|
+
return header.type === "metadata" && typeof header.workdir === "string"
|
|
2147
|
+
? header.workdir
|
|
2148
|
+
: undefined;
|
|
2149
|
+
} finally {
|
|
2150
|
+
await handle.close();
|
|
2151
|
+
}
|
|
2152
|
+
} catch {
|
|
2153
|
+
return undefined;
|
|
2154
|
+
}
|
|
2155
|
+
}
|