wave-code 1.2.0 → 1.4.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 +1738 -893
- package/package.json +6 -3
- package/src/cli.tsx +9 -0
- package/src/commands/plugin/install.ts +6 -7
- package/src/commands/plugin/uninstall.ts +11 -5
- package/src/commands/update.ts +62 -22
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/ChatInterface.tsx +1 -0
- package/src/components/ConfirmationDetails.tsx +51 -16
- package/src/components/ConfirmationSelector.tsx +39 -0
- package/src/components/InputBox.tsx +41 -2
- package/src/components/Markdown.tsx +21 -8
- package/src/components/MarketplaceDetail.tsx +10 -27
- package/src/components/MarketplaceView.tsx +17 -3
- package/src/components/MessageBlockItem.tsx +0 -6
- package/src/components/Notifications.tsx +7 -0
- package/src/components/PlanView.tsx +22 -17
- package/src/components/PluginManagerShell.tsx +3 -3
- package/src/components/PluginManagerTypes.ts +2 -1
- package/src/components/ResumeCommand.tsx +84 -0
- package/src/components/ScrolledContent.tsx +71 -0
- package/src/components/SessionSelector.tsx +184 -6
- package/src/components/SessionSelectorFlow.tsx +246 -0
- package/src/components/WorkflowManager.tsx +38 -138
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +24 -0
- package/src/contexts/useChat.tsx +517 -155
- package/src/daemon/commands.ts +582 -50
- package/src/daemon-cli.ts +12 -1
- package/src/hooks/useInputManager.ts +20 -0
- package/src/hooks/usePluginManager.ts +104 -78
- package/src/hooks/useRemoteControl.ts +324 -0
- package/src/index.ts +97 -8
- package/src/managers/inputHandlers.ts +16 -9
- package/src/managers/inputReducer.ts +30 -9
- package/src/print-cli.ts +5 -0
- package/src/reducers/confirmationReducer.ts +20 -1
- package/src/reducers/selectorReducer.ts +3 -0
- package/src/reducers/workflowManagerReducer.ts +21 -2
- package/src/remote/frameBudget.ts +45 -0
- package/src/remote/hostIdentity.ts +128 -0
- package/src/remote/messageWindow.ts +97 -0
- package/src/remote/notificationCoalescer.ts +174 -0
- package/src/remote/relayClient.ts +668 -0
- package/src/remote/remoteControlCliHost.ts +391 -0
- package/src/remote/remoteControlCommands.ts +197 -0
- package/src/remote/remoteControlConfig.ts +334 -0
- package/src/remote/remoteControlService.ts +799 -0
- package/src/remote/remoteControlSink.ts +77 -0
- package/src/remote/remoteNotifications.ts +194 -0
- package/src/session-selector-cli.tsx +9 -255
- package/src/stdio/agentBridge.ts +1001 -165
- package/src/stdio/daemonServer.ts +30 -0
- package/src/stdio/jsonRpcConnection.ts +11 -2
- package/src/stdio/protocol.ts +14 -106
- package/src/stdio/stdioServer.ts +33 -2
- package/src/stdio-cli.ts +16 -1
- package/src/utils/gitCredentialHelper.ts +106 -0
- package/src/utils/logger.ts +5 -2
- package/src/utils/rewindCheckpoints.ts +2 -4
- package/src/utils/sessionOwnership.ts +62 -0
- package/src/utils/workspaceDiff.ts +620 -0
- package/src/utils/worktree.ts +97 -0
- package/src/components/PlanDisplay.tsx +0 -18
|
@@ -18,10 +18,19 @@ import net from "net";
|
|
|
18
18
|
import * as fs from "fs";
|
|
19
19
|
import { AgentBridge, type AgentBridgeOptions } from "./agentBridge.js";
|
|
20
20
|
import { JsonRpcConnection } from "./jsonRpcConnection.js";
|
|
21
|
+
import type { RemoteControlSink } from "../remote/remoteControlSink.js";
|
|
21
22
|
|
|
22
23
|
export interface DaemonServerOptions {
|
|
23
24
|
socketPath: string;
|
|
24
25
|
bridgeOptions?: AgentBridgeOptions;
|
|
26
|
+
/**
|
|
27
|
+
* Built with the bridge (which the daemon owns privately) so the sink can
|
|
28
|
+
* route inbound phone requests back through it. Its notifications are wired
|
|
29
|
+
* up here; connecting is the user's call, not ours. Unset means remote
|
|
30
|
+
* control is off for this daemon — the `remoteControl*` RPCs then report
|
|
31
|
+
* exactly that through the bridge's own error.
|
|
32
|
+
*/
|
|
33
|
+
createRemoteSink?: (bridge: AgentBridge) => RemoteControlSink;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
export class DaemonServer {
|
|
@@ -30,11 +39,17 @@ export class DaemonServer {
|
|
|
30
39
|
private bridge: AgentBridge;
|
|
31
40
|
private connections = new Set<JsonRpcConnection>();
|
|
32
41
|
private sockets = new Set<net.Socket>();
|
|
42
|
+
private remoteSink: RemoteControlSink | undefined;
|
|
33
43
|
|
|
34
44
|
constructor(options: DaemonServerOptions) {
|
|
35
45
|
this.socketPath = options.socketPath;
|
|
46
|
+
let remoteSink: RemoteControlSink | undefined;
|
|
36
47
|
this.bridge = new AgentBridge({
|
|
37
48
|
...options.bridgeOptions,
|
|
49
|
+
// The sink is handed the bridge it must route phone requests back
|
|
50
|
+
// through, so it can only be built after it; reading it lazily keeps the
|
|
51
|
+
// two from having to be constructed in a circle.
|
|
52
|
+
remoteControl: () => remoteSink,
|
|
38
53
|
// Notifications go to every attached client; a fully detached daemon
|
|
39
54
|
// has none, and the write is dropped silently (the attach snapshot
|
|
40
55
|
// re-syncs state on reconnect).
|
|
@@ -42,11 +57,20 @@ export class DaemonServer {
|
|
|
42
57
|
for (const conn of this.connections) {
|
|
43
58
|
conn.sendNotification(method, params, sessionId);
|
|
44
59
|
}
|
|
60
|
+
// The phone must never be able to take the local clients down with
|
|
61
|
+
// it, so a remote-path failure is contained here rather than thrown.
|
|
62
|
+
try {
|
|
63
|
+
this.remoteSink?.sendNotification(method, params, sessionId);
|
|
64
|
+
} catch {
|
|
65
|
+
// Dropped frame; the phone re-syncs on reconnect.
|
|
66
|
+
}
|
|
45
67
|
},
|
|
46
68
|
// The `shutdown` RPC (wave daemon stop/restart) destroys every session in
|
|
47
69
|
// the bridge, then hands off here to tear the process down.
|
|
48
70
|
onShutdownRequest: () => this.shutdown(),
|
|
49
71
|
});
|
|
72
|
+
remoteSink = options.createRemoteSink?.(this.bridge);
|
|
73
|
+
this.remoteSink = remoteSink;
|
|
50
74
|
this.server = net.createServer((socket) => {
|
|
51
75
|
const conn = new JsonRpcConnection(socket, socket, this.bridge);
|
|
52
76
|
this.connections.add(conn);
|
|
@@ -124,6 +148,8 @@ export class DaemonServer {
|
|
|
124
148
|
|
|
125
149
|
stop(): Promise<void> {
|
|
126
150
|
return new Promise((resolve) => {
|
|
151
|
+
this.remoteSink?.stop();
|
|
152
|
+
this.remoteSink = undefined;
|
|
127
153
|
const server = this.server;
|
|
128
154
|
if (!server) return resolve();
|
|
129
155
|
this.server = undefined;
|
|
@@ -140,6 +166,10 @@ export class DaemonServer {
|
|
|
140
166
|
* teardown.
|
|
141
167
|
*/
|
|
142
168
|
private shutdown(): void {
|
|
169
|
+
// Drop the relay link explicitly: the process is about to exit, and leaving
|
|
170
|
+
// the socket to the OS would have the relay hold a dead subscription.
|
|
171
|
+
this.remoteSink?.stop();
|
|
172
|
+
this.remoteSink = undefined;
|
|
143
173
|
for (const socket of this.sockets) socket.destroy();
|
|
144
174
|
this.sockets.clear();
|
|
145
175
|
this.server?.close();
|
|
@@ -12,9 +12,11 @@ import readline from "readline";
|
|
|
12
12
|
import type { Readable, Writable } from "stream";
|
|
13
13
|
import { AgentBridge } from "./agentBridge.js";
|
|
14
14
|
import {
|
|
15
|
+
type ClientNotificationMethod,
|
|
15
16
|
type JsonRpcRequest,
|
|
16
17
|
type JsonRpcResponse,
|
|
17
18
|
type JsonRpcNotification,
|
|
19
|
+
type RequestMethod,
|
|
18
20
|
PARSE_ERROR,
|
|
19
21
|
INVALID_REQUEST,
|
|
20
22
|
INTERNAL_ERROR,
|
|
@@ -123,8 +125,12 @@ export class JsonRpcConnection {
|
|
|
123
125
|
|
|
124
126
|
private async handleRequest(msg: JsonRpcRequest): Promise<void> {
|
|
125
127
|
try {
|
|
128
|
+
// The boundary and the only cast: JSON-RPC `method` is untrusted input.
|
|
129
|
+
// Anything outside the union still reaches the bridge and is answered by
|
|
130
|
+
// its `default:` branch with -32601 — the type stops at this line, and no
|
|
131
|
+
// runtime allow-list is duplicated here.
|
|
126
132
|
const result = await this.bridge.handleRequest(
|
|
127
|
-
msg.method,
|
|
133
|
+
msg.method as RequestMethod,
|
|
128
134
|
msg.params,
|
|
129
135
|
msg.sessionId,
|
|
130
136
|
);
|
|
@@ -141,7 +147,10 @@ export class JsonRpcConnection {
|
|
|
141
147
|
|
|
142
148
|
private handleNotification(msg: JsonRpcNotification): void {
|
|
143
149
|
try {
|
|
144
|
-
this.bridge.handleNotification(
|
|
150
|
+
this.bridge.handleNotification(
|
|
151
|
+
msg.method as ClientNotificationMethod,
|
|
152
|
+
msg.params,
|
|
153
|
+
);
|
|
145
154
|
} catch (err) {
|
|
146
155
|
// Notifications don't get responses, but we log to stderr
|
|
147
156
|
process.stderr.write(
|
package/src/stdio/protocol.ts
CHANGED
|
@@ -44,112 +44,20 @@ export const METHOD_NOT_FOUND = -32601;
|
|
|
44
44
|
export const INVALID_PARAMS = -32602;
|
|
45
45
|
export const INTERNAL_ERROR = -32603;
|
|
46
46
|
|
|
47
|
-
// ──
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
| "listRewindCheckpoints"
|
|
62
|
-
| "deleteQueuedMessage"
|
|
63
|
-
| "updateQueuedMessage"
|
|
64
|
-
| "deleteQueuedMessageById"
|
|
65
|
-
| "getMessages"
|
|
66
|
-
| "getFullMessageThread"
|
|
67
|
-
| "setPermissionMode"
|
|
68
|
-
| "getPermissionMode"
|
|
69
|
-
| "getPlanFile"
|
|
70
|
-
| "getMcpServers"
|
|
71
|
-
| "connectMcpServer"
|
|
72
|
-
| "disconnectMcpServer"
|
|
73
|
-
| "removeMcpServer"
|
|
74
|
-
| "getMcpConfigPaths"
|
|
75
|
-
| "getSlashCommands"
|
|
76
|
-
| "getSubagentConfigurations"
|
|
77
|
-
| "getSkillMetadata"
|
|
78
|
-
| "deleteSkill"
|
|
79
|
-
| "deleteSubagent"
|
|
80
|
-
| "getHooksByScope"
|
|
81
|
-
| "deleteHook"
|
|
82
|
-
| "searchFiles"
|
|
83
|
-
| "writeArtifactFile"
|
|
84
|
-
| "getPromptHistory"
|
|
85
|
-
| "searchPromptHistory"
|
|
86
|
-
| "updateConfig"
|
|
87
|
-
| "getConfiguredModels"
|
|
88
|
-
| "setModel"
|
|
89
|
-
// Permissions (daemon attach: re-surface pending approvals after reconnect)
|
|
90
|
-
| "listPendingPermissions"
|
|
91
|
-
// Daemon (global — list in-memory session registry, no session required)
|
|
92
|
-
| "listDaemonSessions"
|
|
93
|
-
// Auth
|
|
94
|
-
| "getAuthStatus"
|
|
95
|
-
| "getAccountInfo"
|
|
96
|
-
| "login"
|
|
97
|
-
| "logout"
|
|
98
|
-
// Memory files (user-level ~/.wave/AGENTS.md and project-level <workdir>/AGENTS.md)
|
|
99
|
-
| "getAgentsContent"
|
|
100
|
-
| "setAgentsContent"
|
|
101
|
-
// Plugins
|
|
102
|
-
| "listPlugins"
|
|
103
|
-
| "installPlugin"
|
|
104
|
-
| "uninstallPlugin"
|
|
105
|
-
| "enablePlugin"
|
|
106
|
-
| "disablePlugin"
|
|
107
|
-
| "updatePlugin"
|
|
108
|
-
| "listMarketplaces"
|
|
109
|
-
| "addMarketplace"
|
|
110
|
-
| "removeMarketplace"
|
|
111
|
-
| "updateMarketplace"
|
|
112
|
-
| "compact"
|
|
113
|
-
| "getBackgroundTaskOutput"
|
|
114
|
-
| "stopBackgroundTask"
|
|
115
|
-
| "getWorkflowRuns"
|
|
116
|
-
| "stopWorkflowRun"
|
|
117
|
-
// Git / worktree (global — no session required)
|
|
118
|
-
| "listGitBranches"
|
|
119
|
-
| "createWorktree"
|
|
120
|
-
| "getWorktreeChanges"
|
|
121
|
-
| "removeWorktree";
|
|
122
|
-
|
|
123
|
-
// ── Client → Server notification methods ────────────────────────
|
|
124
|
-
|
|
125
|
-
export type ClientNotificationMethod = "permissionResponse";
|
|
126
|
-
|
|
127
|
-
// ── Server → Client notification methods ────────────────────────
|
|
128
|
-
|
|
129
|
-
export type ServerNotificationMethod =
|
|
130
|
-
| "userMessageAdded"
|
|
131
|
-
| "assistantMessageAdded"
|
|
132
|
-
| "assistantContentUpdated"
|
|
133
|
-
| "assistantReasoningUpdated"
|
|
134
|
-
| "toolBlockUpdated"
|
|
135
|
-
| "errorBlockAdded"
|
|
136
|
-
| "loadingChange"
|
|
137
|
-
| "commandRunningChange"
|
|
138
|
-
| "queuedMessagesChange"
|
|
139
|
-
| "tasksChange"
|
|
140
|
-
| "sessionIdChange"
|
|
141
|
-
| "permissionModeChange"
|
|
142
|
-
| "mcpServersChange"
|
|
143
|
-
| "workdirChange"
|
|
144
|
-
| "notificationMessageAdded"
|
|
145
|
-
| "permissionRequest"
|
|
146
|
-
| "authUrl"
|
|
147
|
-
| "compactBlockAdded"
|
|
148
|
-
| "compactionStateChange"
|
|
149
|
-
| "compactionContentUpdate"
|
|
150
|
-
| "backgroundTasksChange"
|
|
151
|
-
| "btwContent"
|
|
152
|
-
| "contextUsage";
|
|
47
|
+
// ── Method / notification lists ────────────────────────────────
|
|
48
|
+
// Declared once, in the SDK (`src/types/stdioProtocol.ts`), because the RPC
|
|
49
|
+
// client lives there too and both sides of the wire must read the same union.
|
|
50
|
+
// Re-exported here so this package has one module to import from.
|
|
51
|
+
//
|
|
52
|
+
// The compiler — not a source-scanning test — is what keeps them honest: the
|
|
53
|
+
// bridge's `default:` branch is `never`-checked, and `emit` / `RpcClient` are
|
|
54
|
+
// typed with these unions, so a method added on one side without the other does
|
|
55
|
+
// not build.
|
|
56
|
+
export type {
|
|
57
|
+
ClientNotificationMethod,
|
|
58
|
+
RequestMethod,
|
|
59
|
+
ServerNotificationMethod,
|
|
60
|
+
} from "wave-agent-sdk";
|
|
153
61
|
|
|
154
62
|
// ── Helper: is this a request (has id)? ─────────────────────────
|
|
155
63
|
|
package/src/stdio/stdioServer.ts
CHANGED
|
@@ -9,23 +9,47 @@
|
|
|
9
9
|
import type { Readable, Writable } from "stream";
|
|
10
10
|
import { AgentBridge, type AgentBridgeOptions } from "./agentBridge.js";
|
|
11
11
|
import { JsonRpcConnection } from "./jsonRpcConnection.js";
|
|
12
|
+
import type { RemoteControlSink } from "../remote/remoteControlSink.js";
|
|
12
13
|
|
|
13
14
|
export interface StdioServerOptions {
|
|
14
15
|
input?: Readable;
|
|
15
16
|
output?: Writable;
|
|
16
17
|
bridgeOptions?: AgentBridgeOptions;
|
|
18
|
+
/**
|
|
19
|
+
* Same seam as `DaemonServerOptions.createRemoteSink`, for the other server
|
|
20
|
+
* that owns sessions: the `--stdio` child a GUI host spawns. Unset means
|
|
21
|
+
* remote control is off for this child — the `remoteControl*` RPCs then
|
|
22
|
+
* report exactly that through the bridge's own error.
|
|
23
|
+
*/
|
|
24
|
+
createRemoteSink?: (bridge: AgentBridge) => RemoteControlSink;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
export class StdioServer {
|
|
20
28
|
private bridge: AgentBridge;
|
|
21
29
|
private conn: JsonRpcConnection;
|
|
30
|
+
private remoteSink: RemoteControlSink | undefined;
|
|
22
31
|
|
|
23
32
|
constructor(options: StdioServerOptions = {}) {
|
|
33
|
+
let remoteSink: RemoteControlSink | undefined;
|
|
24
34
|
this.bridge = new AgentBridge({
|
|
25
35
|
...options.bridgeOptions,
|
|
26
|
-
|
|
27
|
-
|
|
36
|
+
// The sink is handed the bridge it must route phone requests back
|
|
37
|
+
// through, so it can only be built after it; reading it lazily keeps the
|
|
38
|
+
// two from having to be constructed in a circle.
|
|
39
|
+
remoteControl: () => remoteSink,
|
|
40
|
+
emit: (method, params, sessionId) => {
|
|
41
|
+
this.sendNotification(method, params, sessionId);
|
|
42
|
+
// The phone must never be able to take the host's own client down with
|
|
43
|
+
// it, so a remote-path failure is contained here rather than thrown.
|
|
44
|
+
try {
|
|
45
|
+
this.remoteSink?.sendNotification(method, params, sessionId);
|
|
46
|
+
} catch {
|
|
47
|
+
// Dropped frame; the phone re-syncs on reconnect.
|
|
48
|
+
}
|
|
49
|
+
},
|
|
28
50
|
});
|
|
51
|
+
remoteSink = options.createRemoteSink?.(this.bridge);
|
|
52
|
+
this.remoteSink = remoteSink;
|
|
29
53
|
this.conn = new JsonRpcConnection(
|
|
30
54
|
options.input ?? process.stdin,
|
|
31
55
|
options.output ?? process.stdout,
|
|
@@ -43,6 +67,8 @@ export class StdioServer {
|
|
|
43
67
|
}
|
|
44
68
|
|
|
45
69
|
stop(): void {
|
|
70
|
+
this.remoteSink?.stop();
|
|
71
|
+
this.remoteSink = undefined;
|
|
46
72
|
this.conn.stop();
|
|
47
73
|
}
|
|
48
74
|
|
|
@@ -52,9 +78,14 @@ export class StdioServer {
|
|
|
52
78
|
* auto-memory first; a timeout bounds it so a stuck agent can't orphan the
|
|
53
79
|
* exit. This is the real-world "parent died" path: without it the process
|
|
54
80
|
* lingers forever whenever an agent keeps event-loop handles alive.
|
|
81
|
+
*
|
|
82
|
+
* The relay link is dropped explicitly before the exit: leaving the socket to
|
|
83
|
+
* the OS would have the relay hold a subscription for a process that is gone.
|
|
55
84
|
*/
|
|
56
85
|
private async onConnectionClosed(): Promise<void> {
|
|
57
86
|
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
87
|
+
this.remoteSink?.stop();
|
|
88
|
+
this.remoteSink = undefined;
|
|
58
89
|
await Promise.race([
|
|
59
90
|
this.bridge.destroyAll(),
|
|
60
91
|
new Promise((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS)),
|
package/src/stdio-cli.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* the client sends an "initialize" request.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { ensureRuntimeDeps, setTelemetryStdoutProtected } from "wave-agent-sdk";
|
|
9
10
|
import { StdioServer } from "./stdio/stdioServer.js";
|
|
11
|
+
import { createRemoteControlSinkFactory } from "./remote/remoteControlSink.js";
|
|
10
12
|
import { logger } from "./utils/logger.js";
|
|
11
13
|
|
|
12
14
|
/**
|
|
@@ -54,6 +56,15 @@ export function guardStdoutForJsonRpc(): void {
|
|
|
54
56
|
|
|
55
57
|
export async function startStdioCli(): Promise<void> {
|
|
56
58
|
guardStdoutForJsonRpc();
|
|
59
|
+
// stdout is the JSON-RPC channel here: console telemetry exporters would
|
|
60
|
+
// print onto it (OTel's console exporters use console.dir, which
|
|
61
|
+
// guardStdoutForJsonRpc does not cover) — strip them at config resolution.
|
|
62
|
+
setTelemetryStdoutProtected(true);
|
|
63
|
+
// Install the on-demand runtime dependencies (image codec + ripgrep) before
|
|
64
|
+
// answering any RPC (see cli.tsx). Blocking here is what closes the race: the
|
|
65
|
+
// host's `initialize` — and with it the webview's initial state — only lands
|
|
66
|
+
// once a pasted image can be handled. Failures are non-fatal.
|
|
67
|
+
await ensureRuntimeDeps();
|
|
57
68
|
// Registered here (stdio mode only): the interactive CLI keeps Node's
|
|
58
69
|
// default behavior so the terminal shows the crash stack directly.
|
|
59
70
|
process.on("uncaughtException", (error) =>
|
|
@@ -63,7 +74,11 @@ export async function startStdioCli(): Promise<void> {
|
|
|
63
74
|
crashHandler("unhandledRejection", reason),
|
|
64
75
|
);
|
|
65
76
|
|
|
66
|
-
const server = new StdioServer(
|
|
77
|
+
const server = new StdioServer({
|
|
78
|
+
// This child owns the sessions its host shows, so it — not a daemon on the
|
|
79
|
+
// same machine — is what serves a paired phone.
|
|
80
|
+
createRemoteSink: createRemoteControlSinkFactory(process.cwd()),
|
|
81
|
+
});
|
|
67
82
|
server.start();
|
|
68
83
|
|
|
69
84
|
// Keep the process alive — readline on stdin handles the event loop.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git credential helper installed for repositories cloned from a `wave://clone`
|
|
3
|
+
* deep link (spec: docs/specs/desktop/desktop-deep-link.md「克隆出的仓库长期
|
|
4
|
+
* 自动认证」).
|
|
5
|
+
*
|
|
6
|
+
* What lands in the repository config is *this command*, never the credential:
|
|
7
|
+
*
|
|
8
|
+
* [credential]
|
|
9
|
+
* helper =
|
|
10
|
+
* helper = !exec '/Users/me/.wave/git-credential-wave'
|
|
11
|
+
*
|
|
12
|
+
* The empty first entry resets the helper list inherited from system/global
|
|
13
|
+
* config. Without it a machine-wide store/cache helper (osxkeychain, manager,
|
|
14
|
+
* credential-store) also runs and pins the first credential it sees, so later
|
|
15
|
+
* logins and token refreshes would never be picked up.
|
|
16
|
+
*
|
|
17
|
+
* The helper is a plain `/bin/sh` script rather than a call back into the CLI:
|
|
18
|
+
* git invokes it before *every* authenticated network operation, and booting
|
|
19
|
+
* the Electron-bundled Node runtime for that would add seconds to each push. It
|
|
20
|
+
* only needs the token sitting in `~/.wave/auth.json`, which the CLI keeps
|
|
21
|
+
* current (including background refreshes).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { chmodSync, mkdirSync, writeFileSync } from "fs";
|
|
25
|
+
import * as path from "path";
|
|
26
|
+
import { AuthService } from "wave-agent-sdk";
|
|
27
|
+
|
|
28
|
+
/** Extensionless on purpose: git execs it through the shebang, and MSYS sh
|
|
29
|
+
* resolves a bare path that starts with `#!`. */
|
|
30
|
+
export const GIT_CREDENTIAL_HELPER_FILENAME = "git-credential-wave";
|
|
31
|
+
|
|
32
|
+
export interface InstalledGitCredentialHelper {
|
|
33
|
+
/** Absolute path of the generated script. */
|
|
34
|
+
path: string;
|
|
35
|
+
/** Ready-to-use `credential.helper` value (written into repo config). */
|
|
36
|
+
command: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `<home>/.wave/git-credential-wave` — next to `auth.json`. */
|
|
40
|
+
export function gitCredentialHelperPath(): string {
|
|
41
|
+
return path.join(
|
|
42
|
+
path.dirname(AuthService.getInstance().getAuthPath()),
|
|
43
|
+
GIT_CREDENTIAL_HELPER_FILENAME,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* (Re)write the helper script and return the config value that installs it.
|
|
49
|
+
* Idempotent — safe to call on every deep-link clone.
|
|
50
|
+
*/
|
|
51
|
+
export function installGitCredentialHelper(): InstalledGitCredentialHelper {
|
|
52
|
+
const scriptPath = gitCredentialHelperPath();
|
|
53
|
+
mkdirSync(path.dirname(scriptPath), { recursive: true });
|
|
54
|
+
writeFileSync(
|
|
55
|
+
scriptPath,
|
|
56
|
+
renderGitCredentialHelper(AuthService.getInstance().getAuthPath()),
|
|
57
|
+
"utf-8",
|
|
58
|
+
);
|
|
59
|
+
chmodSync(scriptPath, 0o755);
|
|
60
|
+
return { path: scriptPath, command: `!exec ${shellQuote(scriptPath)}` };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The script body. Exported for tests. */
|
|
64
|
+
export function renderGitCredentialHelper(authFilePath: string): string {
|
|
65
|
+
return `#!/bin/sh
|
|
66
|
+
# Wave git credential helper — generated by CodeWave IDE.
|
|
67
|
+
# Rewritten on every deep-link clone; do not edit by hand.
|
|
68
|
+
#
|
|
69
|
+
# Prints the current Wave SSO token, read fresh from the login store on each
|
|
70
|
+
# request, so logging in (or the CLI refreshing the token) takes effect without
|
|
71
|
+
# touching any repository.
|
|
72
|
+
|
|
73
|
+
AUTH_FILE=${shellQuote(authFilePath)}
|
|
74
|
+
|
|
75
|
+
# Drain git's request: we only look at the capabilities it advertises below, and
|
|
76
|
+
# a helper that exits before reading stdin can get EPIPE'd.
|
|
77
|
+
input=$(cat 2>/dev/null) || input=
|
|
78
|
+
|
|
79
|
+
token=$(sed -n 's/.*"SSO_TOKEN"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' "$AUTH_FILE" 2>/dev/null | head -n 1)
|
|
80
|
+
|
|
81
|
+
if [ -z "$token" ]; then
|
|
82
|
+
# Not logged in on this machine. Stop git right here: quit=true skips the
|
|
83
|
+
# remaining helpers *and* the interactive prompt (there is no terminal when
|
|
84
|
+
# the agent runs git), so the operation fails fast instead of hanging.
|
|
85
|
+
echo "quit=true"
|
|
86
|
+
exit 0
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
case "$input" in
|
|
90
|
+
*capability\\[\\]=authtype*)
|
|
91
|
+
echo "capability[]=authtype"
|
|
92
|
+
echo "authtype=bearer"
|
|
93
|
+
printf 'credential=%s\\n' "$token"
|
|
94
|
+
;;
|
|
95
|
+
*)
|
|
96
|
+
echo "username=oauth2"
|
|
97
|
+
printf 'password=%s\\n' "$token"
|
|
98
|
+
;;
|
|
99
|
+
esac
|
|
100
|
+
`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Single-quote a value for `sh`, escaping embedded single quotes. */
|
|
104
|
+
export function shellQuote(value: string): string {
|
|
105
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
106
|
+
}
|
package/src/utils/logger.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import * as fs from "fs";
|
|
12
12
|
import * as path from "path";
|
|
13
13
|
import { Chalk } from "chalk";
|
|
14
|
-
import { getLastLines } from "wave-agent-sdk";
|
|
14
|
+
import { getLastLines, errorReplacer } from "wave-agent-sdk";
|
|
15
15
|
import { LOG_FILE } from "./constants.js";
|
|
16
16
|
|
|
17
17
|
const chalk = new Chalk({ level: 3 });
|
|
@@ -132,7 +132,10 @@ const formatArg = (arg: unknown): string => {
|
|
|
132
132
|
result = arg.stack || arg.message || String(arg);
|
|
133
133
|
} else if (typeof arg === "object") {
|
|
134
134
|
try {
|
|
135
|
-
|
|
135
|
+
// The replacer keeps Errors nested inside a payload ({ error: e }) from
|
|
136
|
+
// serializing to `{}` — the `instanceof` check above only covers args
|
|
137
|
+
// passed bare.
|
|
138
|
+
result = JSON.stringify(arg, errorReplacer);
|
|
136
139
|
} catch {
|
|
137
140
|
// If JSON.stringify fails, fallback to String()
|
|
138
141
|
result = String(arg);
|
|
@@ -2,15 +2,13 @@ import type { Message } from "wave-agent-sdk";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* 判断一条 user 消息能否作为 /rewind 检查点。
|
|
5
|
-
* 后台任务通知(task_notification
|
|
6
|
-
*
|
|
5
|
+
* 后台任务通知(task_notification)与 hook 注入的消息都是系统生成、
|
|
6
|
+
* 用户不可见的(meta),不能作为回滚点。bash 模式命令消息
|
|
7
7
|
* (`!ls`)是用户真正输入,与 fork skill 命令消息一致,可作为回滚点。
|
|
8
8
|
* CLI 交互式选择器与 stdio listRewindCheckpoints 共用此判定,避免两处漂移。
|
|
9
9
|
*/
|
|
10
10
|
export function isUserCheckpointMessage(m: Message): boolean {
|
|
11
11
|
if (m.role !== "user" || m.isMeta) return false;
|
|
12
12
|
if (m.blocks.some((b) => b.type === "task_notification")) return false;
|
|
13
|
-
if (m.blocks.some((b) => b.type === "text" && b.source === "hook"))
|
|
14
|
-
return false;
|
|
15
13
|
return true;
|
|
16
14
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { PathEncoder } from "wave-agent-sdk";
|
|
3
|
+
|
|
4
|
+
/** POSIX single-quote wrapping for use inside a `cd <path> && ...` command. */
|
|
5
|
+
export function quotePath(p: string): string {
|
|
6
|
+
return `'${p.replace(/'/g, `'\\''`)}'`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type SessionOwnership =
|
|
10
|
+
| { kind: "current" }
|
|
11
|
+
| { kind: "worktree"; resumeWorkdir: string }
|
|
12
|
+
| { kind: "cross-project"; command: string };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Decide how a selected session relates to the current working directory.
|
|
16
|
+
*
|
|
17
|
+
* Ownership is decided by comparing encoded directory names — the decoded
|
|
18
|
+
* workdir is lossy for paths containing "-", but re-encoding it reproduces the
|
|
19
|
+
* original encoded directory name exactly.
|
|
20
|
+
*
|
|
21
|
+
* - current: session belongs to the current directory → resume in place
|
|
22
|
+
* - worktree: session belongs to a sibling same-repo worktree → resume
|
|
23
|
+
* after chdir into it (skipped when the worktree dir is gone)
|
|
24
|
+
* - cross-project: session belongs to another project → print a cd command
|
|
25
|
+
*/
|
|
26
|
+
export async function resolveSessionOwnership(
|
|
27
|
+
session: { id: string; workdir: string },
|
|
28
|
+
opts: { worktreePaths: string[]; currentWorkdir: string },
|
|
29
|
+
): Promise<SessionOwnership> {
|
|
30
|
+
const encoder = new PathEncoder();
|
|
31
|
+
// encode() resolves the path via realpath and throws ENOENT when the
|
|
32
|
+
// directory no longer exists (e.g. a deleted worktree); fall back to the
|
|
33
|
+
// pure string encodeSync so the picker still works for ghost sessions.
|
|
34
|
+
let sessionEncoded: string;
|
|
35
|
+
try {
|
|
36
|
+
sessionEncoded = await encoder.encode(session.workdir);
|
|
37
|
+
} catch {
|
|
38
|
+
sessionEncoded = encoder.encodeSync(session.workdir);
|
|
39
|
+
}
|
|
40
|
+
const cwdEncoded = await encoder.encode(opts.currentWorkdir);
|
|
41
|
+
|
|
42
|
+
if (sessionEncoded === cwdEncoded) {
|
|
43
|
+
return { kind: "current" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const wt of opts.worktreePaths) {
|
|
47
|
+
let wtEncoded: string;
|
|
48
|
+
try {
|
|
49
|
+
wtEncoded = await encoder.encode(wt);
|
|
50
|
+
} catch {
|
|
51
|
+
wtEncoded = encoder.encodeSync(wt);
|
|
52
|
+
}
|
|
53
|
+
if (wtEncoded === sessionEncoded && existsSync(wt)) {
|
|
54
|
+
return { kind: "worktree", resumeWorkdir: wt };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
kind: "cross-project",
|
|
60
|
+
command: `cd ${quotePath(session.workdir)} && wave --restore ${session.id}`,
|
|
61
|
+
};
|
|
62
|
+
}
|