mono-pilot 0.2.10 → 0.2.13

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.
Files changed (155) hide show
  1. package/README.md +260 -2
  2. package/dist/src/agents-paths.js +36 -0
  3. package/dist/src/brief/blocks.js +83 -0
  4. package/dist/src/brief/defaults.js +60 -0
  5. package/dist/src/brief/frontmatter.js +53 -0
  6. package/dist/src/brief/paths.js +10 -0
  7. package/dist/src/brief/reflection.js +27 -0
  8. package/dist/src/cli.js +62 -5
  9. package/dist/src/cluster/bus.js +102 -0
  10. package/dist/src/cluster/follower.js +137 -0
  11. package/dist/src/cluster/init.js +182 -0
  12. package/dist/src/cluster/leader.js +97 -0
  13. package/dist/src/cluster/log.js +49 -0
  14. package/dist/src/cluster/protocol.js +34 -0
  15. package/dist/src/cluster/services/bus.js +243 -0
  16. package/dist/src/cluster/services/embedding.js +12 -0
  17. package/dist/src/cluster/socket.js +86 -0
  18. package/dist/src/cluster/test-bus.js +175 -0
  19. package/dist/src/cluster_v2/connection-lifecycle.js +31 -0
  20. package/dist/src/cluster_v2/connection-lifecycle.test.js +24 -0
  21. package/dist/src/cluster_v2/connection.js +159 -0
  22. package/dist/src/cluster_v2/connection.test.js +55 -0
  23. package/dist/src/cluster_v2/events.js +102 -0
  24. package/dist/src/cluster_v2/index.js +2 -0
  25. package/dist/src/cluster_v2/observability.js +99 -0
  26. package/dist/src/cluster_v2/observability.test.js +46 -0
  27. package/dist/src/cluster_v2/rpc.js +389 -0
  28. package/dist/src/cluster_v2/rpc.test.js +110 -0
  29. package/dist/src/cluster_v2/runtime.failover.integration.test.js +156 -0
  30. package/dist/src/cluster_v2/runtime.js +531 -0
  31. package/dist/src/cluster_v2/runtime.lease-compromise.integration.test.js +91 -0
  32. package/dist/src/cluster_v2/runtime.lifecycle.integration.test.js +225 -0
  33. package/dist/src/cluster_v2/services/bus.integration.test.js +140 -0
  34. package/dist/src/cluster_v2/services/bus.js +450 -0
  35. package/dist/src/cluster_v2/services/discord/auth-store.js +82 -0
  36. package/dist/src/cluster_v2/services/discord/collector.js +569 -0
  37. package/dist/src/cluster_v2/services/discord/index.js +1 -0
  38. package/dist/src/cluster_v2/services/discord/oauth.js +87 -0
  39. package/dist/src/cluster_v2/services/discord/rpc-client.js +325 -0
  40. package/dist/src/cluster_v2/services/embedding.js +66 -0
  41. package/dist/src/cluster_v2/services/registry-cache.js +107 -0
  42. package/dist/src/cluster_v2/services/registry-cache.test.js +66 -0
  43. package/dist/src/cluster_v2/services/registry.js +36 -0
  44. package/dist/src/cluster_v2/services/twitter/collector.js +1055 -0
  45. package/dist/src/cluster_v2/services/twitter/index.js +1 -0
  46. package/dist/src/config/digest.js +78 -0
  47. package/dist/src/config/discord.js +143 -0
  48. package/dist/src/config/image-gen.js +48 -0
  49. package/dist/src/config/mono-pilot.js +31 -0
  50. package/dist/src/config/twitter.js +100 -0
  51. package/dist/src/extensions/cluster.js +311 -0
  52. package/dist/src/extensions/commands/build-memory.js +76 -0
  53. package/dist/src/extensions/commands/digest/backfill.js +779 -0
  54. package/dist/src/extensions/commands/digest/index.js +1133 -0
  55. package/dist/src/extensions/commands/image-model.js +214 -0
  56. package/dist/src/extensions/game/bus-injection.js +47 -0
  57. package/dist/src/extensions/game/identity.js +83 -0
  58. package/dist/src/extensions/game/mailbox.js +61 -0
  59. package/dist/src/extensions/game/system-prompt.js +134 -0
  60. package/dist/src/extensions/game/tools.js +28 -0
  61. package/dist/src/extensions/lifecycle.js +337 -0
  62. package/dist/src/extensions/mode-runtime.js +26 -2
  63. package/dist/src/extensions/mono-game.js +66 -0
  64. package/dist/src/extensions/mono-pilot.js +100 -18
  65. package/dist/src/extensions/nvim.js +47 -0
  66. package/dist/src/extensions/session-hints.js +1 -2
  67. package/dist/src/extensions/sftp.js +897 -0
  68. package/dist/src/extensions/status.js +676 -0
  69. package/dist/src/extensions/system-events.js +478 -0
  70. package/dist/src/extensions/system-prompt.js +24 -14
  71. package/dist/src/extensions/user-message.js +70 -1
  72. package/dist/src/lsp/client.js +235 -0
  73. package/dist/src/lsp/index.js +165 -0
  74. package/dist/src/lsp/runtime.js +67 -0
  75. package/dist/src/lsp/server.js +242 -0
  76. package/dist/src/memory/build-memory.js +103 -0
  77. package/dist/src/memory/config/defaults.js +55 -0
  78. package/dist/src/memory/config/loader.js +29 -0
  79. package/dist/src/memory/config/paths.js +9 -0
  80. package/dist/src/memory/config/resolve.js +90 -0
  81. package/dist/src/memory/config/types.js +1 -0
  82. package/dist/src/memory/embeddings/batch-runner.js +39 -0
  83. package/dist/src/memory/embeddings/cache.js +47 -0
  84. package/dist/src/memory/embeddings/chunk-limits.js +26 -0
  85. package/dist/src/memory/embeddings/input-limits.js +48 -0
  86. package/dist/src/memory/embeddings/local.js +108 -0
  87. package/dist/src/memory/embeddings/types.js +1 -0
  88. package/dist/src/memory/index-manager.js +552 -0
  89. package/dist/src/memory/indexing/embeddings.js +67 -0
  90. package/dist/src/memory/indexing/files.js +180 -0
  91. package/dist/src/memory/indexing/index-file.js +105 -0
  92. package/dist/src/memory/log.js +38 -0
  93. package/dist/src/memory/paths.js +15 -0
  94. package/dist/src/memory/runtime/index.js +299 -0
  95. package/dist/src/memory/runtime/thread.js +116 -0
  96. package/dist/src/memory/search/fts.js +57 -0
  97. package/dist/src/memory/search/hybrid.js +50 -0
  98. package/dist/src/memory/search/text.js +30 -0
  99. package/dist/src/memory/search/vector.js +43 -0
  100. package/dist/src/memory/session/content-hash.js +7 -0
  101. package/dist/src/memory/session/entry.js +33 -0
  102. package/dist/src/memory/session/flush-policy.js +34 -0
  103. package/dist/src/memory/session/hook.js +191 -0
  104. package/dist/src/memory/session/paths.js +15 -0
  105. package/dist/src/memory/session/session-reader.js +88 -0
  106. package/dist/src/memory/session/transcript/content-hash.js +7 -0
  107. package/dist/src/memory/session/transcript/entry.js +28 -0
  108. package/dist/src/memory/session/transcript/flush.js +56 -0
  109. package/dist/src/memory/session/transcript/paths.js +28 -0
  110. package/dist/src/memory/session/transcript/reader.js +112 -0
  111. package/dist/src/memory/session/transcript/state.js +31 -0
  112. package/dist/src/memory/store/schema.js +89 -0
  113. package/dist/src/memory/store/sqlite.js +89 -0
  114. package/dist/src/memory/types.js +1 -0
  115. package/dist/src/memory/warm.js +25 -0
  116. package/dist/{tools → src/tools}/README.md +28 -2
  117. package/dist/{tools → src/tools}/apply-patch-description.md +8 -2
  118. package/dist/{tools → src/tools}/apply-patch.js +174 -104
  119. package/dist/{tools → src/tools}/apply-patch.test.js +52 -1
  120. package/dist/{tools/ask-question.js → src/tools/ask-user-question.js} +3 -3
  121. package/dist/src/tools/ast-grep.js +357 -0
  122. package/dist/src/tools/brief-write.js +122 -0
  123. package/dist/src/tools/bus-send.js +100 -0
  124. package/dist/{tools → src/tools}/call-mcp-tool.js +20 -24
  125. package/dist/src/tools/codex-apply-patch-description.md +52 -0
  126. package/dist/src/tools/codex-apply-patch.js +540 -0
  127. package/dist/{tools → src/tools}/delete.js +24 -0
  128. package/dist/src/tools/exit-plan-mode.js +83 -0
  129. package/dist/{tools → src/tools}/fetch-mcp-resource.js +31 -3
  130. package/dist/src/tools/generate-image.js +567 -0
  131. package/dist/{tools → src/tools}/glob.js +55 -1
  132. package/dist/{tools → src/tools}/list-mcp-resources.js +32 -3
  133. package/dist/{tools → src/tools}/list-mcp-tools.js +38 -3
  134. package/dist/src/tools/ls.js +48 -0
  135. package/dist/src/tools/lsp-diagnostics.js +67 -0
  136. package/dist/src/tools/lsp-symbols.js +54 -0
  137. package/dist/src/tools/mailbox.js +85 -0
  138. package/dist/src/tools/memory-get.js +90 -0
  139. package/dist/src/tools/memory-search.js +180 -0
  140. package/dist/{tools → src/tools}/plan-mode-reminder.md +3 -4
  141. package/dist/{tools → src/tools}/read-file.js +8 -19
  142. package/dist/{tools → src/tools}/rg.js +10 -20
  143. package/dist/{tools → src/tools}/shell.js +19 -42
  144. package/dist/{tools → src/tools}/subagent.js +255 -6
  145. package/dist/{tools → src/tools}/switch-mode.js +37 -6
  146. package/dist/{tools → src/tools}/web-fetch.js +105 -7
  147. package/dist/{tools → src/tools}/web-search.js +29 -1
  148. package/package.json +21 -9
  149. package/dist/src/utils/mcp-client.js +0 -282
  150. /package/dist/{tools → src/tools}/ask-mode-reminder.md +0 -0
  151. /package/dist/{tools → src/tools}/rg.test.js +0 -0
  152. /package/dist/{tools → src/tools}/semantic-search-description.md +0 -0
  153. /package/dist/{tools → src/tools}/semantic-search.js +0 -0
  154. /package/dist/{tools → src/tools}/shell-description.md +0 -0
  155. /package/dist/{tools → src/tools}/subagent-description.md +0 -0
@@ -0,0 +1,897 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { mkdir, readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, isAbsolute, posix, relative, resolve, sep } from "node:path";
5
+ import { publishSystemEvent } from "./system-events.js";
6
+ const connectionCache = new Map();
7
+ function isRecord(value) {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
10
+ function readString(value) {
11
+ return typeof value === "string" ? value : undefined;
12
+ }
13
+ function readBoolean(value) {
14
+ return typeof value === "boolean" ? value : undefined;
15
+ }
16
+ function readNumber(value) {
17
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
18
+ }
19
+ function expandHome(value) {
20
+ if (value === "~")
21
+ return homedir();
22
+ if (value.startsWith("~/")) {
23
+ return resolve(homedir(), value.slice(2));
24
+ }
25
+ return value;
26
+ }
27
+ function normalizeTarget(raw) {
28
+ const protocol = readString(raw.protocol);
29
+ if (protocol && protocol !== "sftp")
30
+ return null;
31
+ const host = readString(raw.host);
32
+ const username = readString(raw.username);
33
+ const remotePath = readString(raw.remotePath);
34
+ if (!host || !username || !remotePath) {
35
+ return null;
36
+ }
37
+ const privateKeyPath = readString(raw.privateKeyPath);
38
+ const hop = normalizeHop(raw.hop);
39
+ if (hop === null) {
40
+ return null;
41
+ }
42
+ return {
43
+ name: readString(raw.name),
44
+ protocol,
45
+ host,
46
+ port: readNumber(raw.port),
47
+ username,
48
+ password: readString(raw.password),
49
+ privateKeyPath: privateKeyPath ? expandHome(privateKeyPath) : undefined,
50
+ privateKey: readString(raw.privateKey),
51
+ passphrase: readString(raw.passphrase),
52
+ remotePath,
53
+ uploadOnSave: readBoolean(raw.uploadOnSave),
54
+ interactiveAuth: readBoolean(raw.interactiveAuth),
55
+ hop,
56
+ };
57
+ }
58
+ function normalizeHop(raw) {
59
+ if (raw === undefined) {
60
+ return undefined;
61
+ }
62
+ if (!isRecord(raw)) {
63
+ return null;
64
+ }
65
+ const host = readString(raw.host);
66
+ const username = readString(raw.username);
67
+ if (!host || !username) {
68
+ return null;
69
+ }
70
+ const privateKeyPath = readString(raw.privateKeyPath);
71
+ return {
72
+ host,
73
+ port: readNumber(raw.port),
74
+ username,
75
+ password: readString(raw.password),
76
+ privateKeyPath: privateKeyPath ? expandHome(privateKeyPath) : undefined,
77
+ privateKey: readString(raw.privateKey),
78
+ passphrase: readString(raw.passphrase),
79
+ interactiveAuth: readBoolean(raw.interactiveAuth),
80
+ };
81
+ }
82
+ function toPosixPath(value) {
83
+ return value.split(sep).join(posix.sep);
84
+ }
85
+ function resolveLocalPath(cwd, targetPath) {
86
+ return isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath);
87
+ }
88
+ function isPathWithinCwd(cwd, targetPath) {
89
+ const resolved = resolve(targetPath);
90
+ const rel = relative(cwd, resolved);
91
+ if (!rel) {
92
+ return false;
93
+ }
94
+ return !rel.startsWith("..") && !rel.includes(".." + sep);
95
+ }
96
+ function buildRemotePath(remoteRoot, localPath, cwd) {
97
+ const rel = relative(cwd, localPath);
98
+ if (!rel || rel.startsWith("..") || rel.includes(".." + sep)) {
99
+ return null;
100
+ }
101
+ return posix.join(remoteRoot, toPosixPath(rel));
102
+ }
103
+ function describeTarget(target) {
104
+ return target.name ? `${target.name} (${target.host})` : target.host;
105
+ }
106
+ function getTargetCacheKey(target) {
107
+ const base = target.name ?? target.host;
108
+ if (!target.hop) {
109
+ return base;
110
+ }
111
+ return `${base} via ${target.hop.username}@${target.hop.host}:${target.hop.port ?? 22}`;
112
+ }
113
+ function targetRequiresInteractiveAuth(target) {
114
+ return target.interactiveAuth === true || target.hop?.interactiveAuth === true;
115
+ }
116
+ function shouldRetryInteractiveAuth(errors) {
117
+ if (!errors || errors.length === 0)
118
+ return false;
119
+ return errors.some((err) => {
120
+ const text = err.toLowerCase();
121
+ return (text.includes("authentication methods failed") ||
122
+ text.includes("authentication failed") ||
123
+ text.includes("timed out while waiting for handshake"));
124
+ });
125
+ }
126
+ function hasSftpConnection(target) {
127
+ const entry = connectionCache.get(getTargetCacheKey(target));
128
+ return Boolean(entry?.ready);
129
+ }
130
+ function buildConnectConfig(target) {
131
+ const config = {
132
+ host: target.host,
133
+ port: target.port ?? 22,
134
+ username: target.username,
135
+ };
136
+ if (target.password) {
137
+ config.password = target.password;
138
+ }
139
+ if (target.privateKey) {
140
+ config.privateKey = target.privateKey;
141
+ }
142
+ if (target.privateKeyPath) {
143
+ config.privateKey = readFileSync(target.privateKeyPath);
144
+ }
145
+ if (target.passphrase) {
146
+ config.passphrase = target.passphrase;
147
+ }
148
+ if (target.interactiveAuth) {
149
+ config.tryKeyboard = true;
150
+ }
151
+ return config;
152
+ }
153
+ function registerConnectionLifecycle(key, entry) {
154
+ const drop = () => {
155
+ if (entry.cleanup) {
156
+ entry.cleanup();
157
+ entry.cleanup = undefined;
158
+ }
159
+ const current = connectionCache.get(key);
160
+ if (current === entry) {
161
+ connectionCache.delete(key);
162
+ }
163
+ };
164
+ entry.client.on("close", drop);
165
+ entry.client.on("end", drop);
166
+ entry.client.on("error", drop);
167
+ }
168
+ async function createSftpClient() {
169
+ const { default: SftpClient } = await import("ssh2-sftp-client");
170
+ const ClientCtor = SftpClient;
171
+ return new ClientCtor("mono-pilot-sftp", {
172
+ error: () => {
173
+ // Operation-level errors are handled by promise rejections.
174
+ },
175
+ end: () => {
176
+ // Suppress default global end log spam in interactive TUI.
177
+ },
178
+ close: () => {
179
+ // Suppress default global close log spam in interactive TUI.
180
+ },
181
+ });
182
+ }
183
+ async function createSshTunnelClient() {
184
+ const ssh2Module = (await import("ssh2"));
185
+ return new ssh2Module.Client();
186
+ }
187
+ async function createForwardStreamViaHop(options) {
188
+ const hopClient = await createSshTunnelClient();
189
+ if (options.hop.interactiveAuth && options.attachKeyboardInteractive) {
190
+ options.attachKeyboardInteractive(hopClient);
191
+ }
192
+ await new Promise((resolveReady, rejectReady) => {
193
+ const onReady = () => {
194
+ hopClient.removeListener("error", onError);
195
+ resolveReady();
196
+ };
197
+ const onError = (error) => {
198
+ hopClient.removeListener("ready", onReady);
199
+ rejectReady(error instanceof Error ? error : new Error(String(error)));
200
+ };
201
+ hopClient.once("ready", onReady);
202
+ hopClient.once("error", onError);
203
+ hopClient.connect(buildConnectConfig(options.hop));
204
+ });
205
+ const stream = await new Promise((resolveStream, rejectStream) => {
206
+ hopClient.forwardOut("127.0.0.1", 0, options.targetHost, options.targetPort, (err, forwarded) => {
207
+ if (err) {
208
+ rejectStream(err);
209
+ return;
210
+ }
211
+ resolveStream(forwarded);
212
+ });
213
+ }).catch((error) => {
214
+ try {
215
+ hopClient.end();
216
+ }
217
+ catch {
218
+ // Best effort cleanup.
219
+ }
220
+ throw error;
221
+ });
222
+ return { client: hopClient, stream };
223
+ }
224
+ async function getOrCreateConnection(options) {
225
+ const key = getTargetCacheKey(options.target);
226
+ const existing = connectionCache.get(key);
227
+ if (options.requireExisting && !existing?.ready) {
228
+ throw new Error("No active SFTP session");
229
+ }
230
+ if (existing?.ready) {
231
+ return existing.client;
232
+ }
233
+ if (!existing) {
234
+ const client = await createSftpClient();
235
+ const entry = { client, ready: false };
236
+ connectionCache.set(key, entry);
237
+ registerConnectionLifecycle(key, entry);
238
+ }
239
+ const entry = connectionCache.get(key);
240
+ if (!entry) {
241
+ throw new Error("Failed to initialize SFTP client");
242
+ }
243
+ if (!entry.connecting) {
244
+ entry.connecting = (async () => {
245
+ const shouldHandleOtp = targetRequiresInteractiveAuth(options.target) && (options.otp || options.otpProvider);
246
+ let attachKeyboardInteractive;
247
+ if (shouldHandleOtp) {
248
+ let resolvedOtp = options.otp ?? undefined;
249
+ let otpPromise = null;
250
+ const resolveOtp = async () => {
251
+ if (resolvedOtp !== undefined) {
252
+ return resolvedOtp;
253
+ }
254
+ if (!options.otpProvider) {
255
+ resolvedOtp = null;
256
+ return resolvedOtp;
257
+ }
258
+ if (!otpPromise) {
259
+ otpPromise = options.otpProvider();
260
+ }
261
+ resolvedOtp = await otpPromise;
262
+ return resolvedOtp;
263
+ };
264
+ attachKeyboardInteractive = (client) => {
265
+ client.on("keyboard-interactive", (_name, _instructions, _lang, prompts, finish) => {
266
+ void (async () => {
267
+ const otp = await resolveOtp();
268
+ const answers = (Array.isArray(prompts) ? prompts : []).map(() => otp ?? "");
269
+ if (typeof finish === "function") {
270
+ finish(answers);
271
+ }
272
+ })();
273
+ });
274
+ };
275
+ }
276
+ if (options.target.hop) {
277
+ const tunnel = await createForwardStreamViaHop({
278
+ hop: options.target.hop,
279
+ targetHost: options.target.host,
280
+ targetPort: options.target.port ?? 22,
281
+ attachKeyboardInteractive,
282
+ });
283
+ entry.cleanup = () => {
284
+ try {
285
+ tunnel.client.end();
286
+ }
287
+ catch {
288
+ // Best effort tunnel cleanup.
289
+ }
290
+ };
291
+ if (options.target.interactiveAuth && attachKeyboardInteractive) {
292
+ attachKeyboardInteractive(entry.client);
293
+ }
294
+ await entry.client.connect({
295
+ ...buildConnectConfig(options.target),
296
+ sock: tunnel.stream,
297
+ });
298
+ }
299
+ else {
300
+ if (options.target.interactiveAuth && attachKeyboardInteractive) {
301
+ attachKeyboardInteractive(entry.client);
302
+ }
303
+ await entry.client.connect(buildConnectConfig(options.target));
304
+ }
305
+ entry.ready = true;
306
+ })().catch((error) => {
307
+ if (entry.cleanup) {
308
+ entry.cleanup();
309
+ entry.cleanup = undefined;
310
+ }
311
+ connectionCache.delete(key);
312
+ throw error;
313
+ });
314
+ }
315
+ await entry.connecting;
316
+ return entry.client;
317
+ }
318
+ async function loadSftpTargets(cwd) {
319
+ const configPath = resolve(cwd, ".vscode/sftp.json");
320
+ if (!existsSync(configPath)) {
321
+ return [];
322
+ }
323
+ const rawText = await readFile(configPath, "utf-8");
324
+ let parsed;
325
+ try {
326
+ parsed = JSON.parse(rawText);
327
+ }
328
+ catch (error) {
329
+ const message = error instanceof Error ? error.message : String(error);
330
+ throw new Error(`Invalid .vscode/sftp.json: ${message}`);
331
+ }
332
+ const entries = Array.isArray(parsed) ? parsed : isRecord(parsed) ? [parsed] : [];
333
+ return entries
334
+ .map((entry) => (isRecord(entry) ? normalizeTarget(entry) : null))
335
+ .filter((entry) => Boolean(entry))
336
+ .filter((entry) => entry.uploadOnSave !== false);
337
+ }
338
+ async function syncSftpFile(options) {
339
+ const targets = options.targets;
340
+ const labels = targets.map((target) => describeTarget(target));
341
+ const errors = [];
342
+ let uploaded = 0;
343
+ const localAbsolute = resolve(options.localPath);
344
+ if (!existsSync(localAbsolute)) {
345
+ return {
346
+ targets: labels,
347
+ uploaded,
348
+ errors: [`local file missing: ${localAbsolute}`],
349
+ };
350
+ }
351
+ for (const target of targets) {
352
+ const label = describeTarget(target);
353
+ if (targetRequiresInteractiveAuth(target)) {
354
+ if (options.requireExisting && !hasSftpConnection(target)) {
355
+ errors.push(`${label}: no active SFTP session`);
356
+ continue;
357
+ }
358
+ }
359
+ const remotePath = buildRemotePath(target.remotePath, localAbsolute, options.cwd);
360
+ if (!remotePath) {
361
+ errors.push(`${label}: file outside workspace`);
362
+ continue;
363
+ }
364
+ try {
365
+ const client = await getOrCreateConnection({
366
+ target,
367
+ requireExisting: options.requireExisting,
368
+ otp: options.otp,
369
+ otpProvider: options.otpProvider,
370
+ });
371
+ await client.mkdir(posix.dirname(remotePath), true);
372
+ await client.put(localAbsolute, remotePath);
373
+ uploaded += 1;
374
+ }
375
+ catch (error) {
376
+ const message = error instanceof Error ? error.message : String(error);
377
+ errors.push(`${label}: ${message}`);
378
+ }
379
+ }
380
+ return {
381
+ targets: labels,
382
+ uploaded,
383
+ errors: errors.length > 0 ? errors : undefined,
384
+ };
385
+ }
386
+ function isApplyPatchResultDetails(value) {
387
+ if (!isRecord(value)) {
388
+ return false;
389
+ }
390
+ const operation = value.operation;
391
+ if (operation !== "add" && operation !== "update") {
392
+ return false;
393
+ }
394
+ if (typeof value.path !== "string" || value.path.trim().length === 0) {
395
+ return false;
396
+ }
397
+ if (value.moveTo !== undefined && typeof value.moveTo !== "string") {
398
+ return false;
399
+ }
400
+ if (value.appliedHunks !== undefined && typeof value.appliedHunks !== "number") {
401
+ return false;
402
+ }
403
+ return true;
404
+ }
405
+ function isStringArray(value) {
406
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
407
+ }
408
+ function isCodexApplyPatchResultDetails(value) {
409
+ if (!isRecord(value)) {
410
+ return false;
411
+ }
412
+ if (!isStringArray(value.added) || !isStringArray(value.modified)) {
413
+ return false;
414
+ }
415
+ if (value.deleted !== undefined && !isStringArray(value.deleted)) {
416
+ return false;
417
+ }
418
+ return true;
419
+ }
420
+ function isSftpSyncDetails(value) {
421
+ if (!isRecord(value)) {
422
+ return false;
423
+ }
424
+ return Array.isArray(value.targets) && typeof value.uploaded === "number";
425
+ }
426
+ async function prepareAutoSyncTarget(cwd, context) {
427
+ let targets;
428
+ try {
429
+ targets = await loadSftpTargets(cwd);
430
+ }
431
+ catch (error) {
432
+ const message = error instanceof Error ? error.message : String(error);
433
+ return {
434
+ targets: [],
435
+ uploaded: 0,
436
+ errors: [message],
437
+ };
438
+ }
439
+ if (targets.length === 0) {
440
+ return undefined;
441
+ }
442
+ const preferredTarget = selectedTargetId ? pickTarget(targets, selectedTargetId) : undefined;
443
+ if (selectedTargetId && !preferredTarget) {
444
+ return {
445
+ targets: targets.map((target) => describeTarget(target)),
446
+ uploaded: 0,
447
+ errors: [`selected target not found: ${selectedTargetId}`],
448
+ };
449
+ }
450
+ const selectedTarget = preferredTarget ?? targets[targets.length - 1];
451
+ const otpProvider = targetRequiresInteractiveAuth(selectedTarget) && context?.hasUI
452
+ ? async () => {
453
+ const labels = [selectedTarget.name ?? selectedTarget.host];
454
+ return await promptForOtp(context, labels);
455
+ }
456
+ : undefined;
457
+ return {
458
+ target: selectedTarget,
459
+ requireExisting: targetRequiresInteractiveAuth(selectedTarget) && !otpProvider,
460
+ otpProvider,
461
+ };
462
+ }
463
+ function shouldSyncForApplyPatch(details) {
464
+ if (details.operation === "add") {
465
+ return true;
466
+ }
467
+ if (details.moveTo) {
468
+ return true;
469
+ }
470
+ return typeof details.appliedHunks === "number" && details.appliedHunks > 0;
471
+ }
472
+ async function maybeSyncApplyPatchResult(cwd, details, context) {
473
+ if (!shouldSyncForApplyPatch(details)) {
474
+ return undefined;
475
+ }
476
+ const localPath = details.moveTo ?? details.path;
477
+ if (!isPathWithinCwd(cwd, localPath)) {
478
+ return undefined;
479
+ }
480
+ const prepared = await prepareAutoSyncTarget(cwd, context);
481
+ if (!prepared) {
482
+ return undefined;
483
+ }
484
+ if (isSftpSyncDetails(prepared)) {
485
+ return prepared;
486
+ }
487
+ return syncSftpFile({
488
+ cwd,
489
+ localPath,
490
+ targets: [prepared.target],
491
+ requireExisting: prepared.requireExisting,
492
+ otpProvider: prepared.otpProvider,
493
+ });
494
+ }
495
+ async function maybeSyncCodexApplyPatchResult(cwd, details, context) {
496
+ const dedupedPaths = Array.from(new Set([...details.added, ...details.modified].map((entry) => entry.trim())))
497
+ .filter((entry) => entry.length > 0)
498
+ .filter((entry) => isPathWithinCwd(cwd, entry));
499
+ if (dedupedPaths.length === 0) {
500
+ return undefined;
501
+ }
502
+ const prepared = await prepareAutoSyncTarget(cwd, context);
503
+ if (!prepared) {
504
+ return undefined;
505
+ }
506
+ if (isSftpSyncDetails(prepared)) {
507
+ return prepared;
508
+ }
509
+ let uploaded = 0;
510
+ const errors = [];
511
+ let targets = [];
512
+ for (const localPath of dedupedPaths) {
513
+ const result = await syncSftpFile({
514
+ cwd,
515
+ localPath,
516
+ targets: [prepared.target],
517
+ requireExisting: prepared.requireExisting,
518
+ otpProvider: prepared.otpProvider,
519
+ });
520
+ uploaded += result.uploaded;
521
+ targets = result.targets;
522
+ if (result.errors && result.errors.length > 0) {
523
+ errors.push(`${localPath}: ${result.errors.join("; ")}`);
524
+ }
525
+ }
526
+ return {
527
+ targets,
528
+ uploaded,
529
+ errors: errors.length > 0 ? errors : undefined,
530
+ };
531
+ }
532
+ async function uploadSftpPath(options) {
533
+ const targets = options.targets;
534
+ const labels = targets.map((target) => describeTarget(target));
535
+ const errors = [];
536
+ let uploaded = 0;
537
+ const localAbsolute = resolveLocalPath(options.cwd, options.localPath);
538
+ if (!existsSync(localAbsolute)) {
539
+ return {
540
+ targets: labels,
541
+ uploaded,
542
+ errors: [`local path missing: ${localAbsolute}`],
543
+ };
544
+ }
545
+ const stats = statSync(localAbsolute);
546
+ const isDirectory = stats.isDirectory();
547
+ for (const target of targets) {
548
+ const label = describeTarget(target);
549
+ if (targetRequiresInteractiveAuth(target)) {
550
+ if (options.requireExisting && !hasSftpConnection(target)) {
551
+ errors.push(`${label}: no active SFTP session`);
552
+ continue;
553
+ }
554
+ }
555
+ const remotePath = buildRemotePath(target.remotePath, localAbsolute, options.cwd);
556
+ if (!remotePath) {
557
+ errors.push(`${label}: path outside workspace`);
558
+ continue;
559
+ }
560
+ try {
561
+ const client = await getOrCreateConnection({
562
+ target,
563
+ requireExisting: options.requireExisting,
564
+ otp: options.otp,
565
+ otpProvider: options.otpProvider,
566
+ });
567
+ if (isDirectory) {
568
+ await client.uploadDir(localAbsolute, remotePath);
569
+ }
570
+ else {
571
+ await client.mkdir(posix.dirname(remotePath), true);
572
+ await client.put(localAbsolute, remotePath);
573
+ }
574
+ uploaded += 1;
575
+ }
576
+ catch (error) {
577
+ const message = error instanceof Error ? error.message : String(error);
578
+ errors.push(`${label}: ${message}`);
579
+ }
580
+ }
581
+ return {
582
+ targets: labels,
583
+ uploaded,
584
+ errors: errors.length > 0 ? errors : undefined,
585
+ };
586
+ }
587
+ async function downloadSftpPath(options) {
588
+ const targets = options.targets;
589
+ const labels = targets.map((target) => describeTarget(target));
590
+ const errors = [];
591
+ let downloaded = 0;
592
+ const localAbsolute = resolveLocalPath(options.cwd, options.localPath);
593
+ for (const target of targets) {
594
+ const label = describeTarget(target);
595
+ if (targetRequiresInteractiveAuth(target)) {
596
+ if (options.requireExisting && !hasSftpConnection(target)) {
597
+ errors.push(`${label}: no active SFTP session`);
598
+ continue;
599
+ }
600
+ }
601
+ const remotePath = buildRemotePath(target.remotePath, localAbsolute, options.cwd);
602
+ if (!remotePath) {
603
+ errors.push(`${label}: path outside workspace`);
604
+ continue;
605
+ }
606
+ try {
607
+ const client = await getOrCreateConnection({
608
+ target,
609
+ requireExisting: options.requireExisting,
610
+ otp: options.otp,
611
+ otpProvider: options.otpProvider,
612
+ });
613
+ const exists = await client.exists(remotePath);
614
+ if (!exists) {
615
+ errors.push(`${label}: remote path not found`);
616
+ continue;
617
+ }
618
+ if (exists === "d") {
619
+ await client.downloadDir(remotePath, localAbsolute);
620
+ }
621
+ else {
622
+ await mkdir(dirname(localAbsolute), { recursive: true });
623
+ await client.fastGet(remotePath, localAbsolute);
624
+ }
625
+ downloaded += 1;
626
+ }
627
+ catch (error) {
628
+ const message = error instanceof Error ? error.message : String(error);
629
+ errors.push(`${label}: ${message}`);
630
+ }
631
+ }
632
+ return {
633
+ targets: labels,
634
+ uploaded: downloaded,
635
+ errors: errors.length > 0 ? errors : undefined,
636
+ };
637
+ }
638
+ const SFTP_USAGE = [
639
+ "Usage:",
640
+ " /sftp",
641
+ " /sftp upload <path>",
642
+ " /sftp download <path>",
643
+ " /sftp target <targetName>",
644
+ ].join("\n");
645
+ let selectedTargetId;
646
+ function getTargetId(target) {
647
+ return target.name ?? target.host;
648
+ }
649
+ function parseSubcommand(input) {
650
+ const trimmed = input.trim();
651
+ if (!trimmed) {
652
+ return { cmd: "select" };
653
+ }
654
+ const lower = trimmed.toLowerCase();
655
+ if (lower.startsWith("target ") || lower === "target") {
656
+ const rawName = trimmed.slice("target".length).trim();
657
+ return { cmd: "target", name: rawName || undefined };
658
+ }
659
+ const [commandRaw, ...rest] = trimmed.split(/\s+/);
660
+ const command = commandRaw.toLowerCase();
661
+ const path = rest.join(" ").trim();
662
+ if (command !== "upload" && command !== "download") {
663
+ return {};
664
+ }
665
+ return {
666
+ cmd: command,
667
+ path: path || undefined,
668
+ };
669
+ }
670
+ function notify(ctx, message, level) {
671
+ if (level !== "info") {
672
+ publishSystemEvent({
673
+ source: "sftp",
674
+ level,
675
+ message,
676
+ toast: false,
677
+ ctx,
678
+ });
679
+ }
680
+ if (ctx.hasUI && ctx.ui?.notify) {
681
+ if (level === "info") {
682
+ ctx.ui.notify(message, level);
683
+ }
684
+ return;
685
+ }
686
+ const prefix = level === "error" ? "[error]" : level === "warning" ? "[warn]" : "[info]";
687
+ console.log(`${prefix} ${message}`);
688
+ }
689
+ function formatTargets(targets) {
690
+ return targets.length > 0 ? targets.join(", ") : "(none)";
691
+ }
692
+ function describeTargetName(name, host) {
693
+ return name ? `${name} (${host})` : host;
694
+ }
695
+ function renderTargetList(targets) {
696
+ if (targets.length === 0)
697
+ return "(none)";
698
+ return targets
699
+ .map((target, index) => {
700
+ const label = describeTargetName(target.name, target.host);
701
+ const isSelected = (selectedTargetId && getTargetId(target) === selectedTargetId) ||
702
+ (!selectedTargetId && index === targets.length - 1);
703
+ return isSelected ? `* ${label}` : ` ${label}`;
704
+ })
705
+ .join("\n");
706
+ }
707
+ function pickTarget(targets, id) {
708
+ if (!id)
709
+ return undefined;
710
+ const named = targets.find((target) => target.name === id);
711
+ if (named) {
712
+ return named;
713
+ }
714
+ return targets.find((target) => getTargetId(target) === id);
715
+ }
716
+ async function promptForOtp(ctx, labels) {
717
+ if (!ctx.hasUI) {
718
+ return null;
719
+ }
720
+ const title = labels.length === 1 ? `SFTP OTP (${labels[0]})` : `SFTP OTP (${labels.length} targets)`;
721
+ const value = await ctx.ui.input(title, "Enter one-time code");
722
+ const normalized = value?.trim();
723
+ return normalized && normalized.length > 0 ? normalized : null;
724
+ }
725
+ async function promptForTargetSelection(ctx, targets) {
726
+ if (!ctx.hasUI || !ctx.ui?.select) {
727
+ return null;
728
+ }
729
+ const fallbackIndex = Math.max(0, targets.length - 1);
730
+ const preferredIndexRaw = selectedTargetId
731
+ ? targets.findIndex((target) => getTargetId(target) === selectedTargetId)
732
+ : fallbackIndex;
733
+ const preferredIndex = preferredIndexRaw >= 0 ? preferredIndexRaw : fallbackIndex;
734
+ const preferredTarget = targets[preferredIndex];
735
+ const orderedTargets = preferredTarget
736
+ ? [preferredTarget, ...targets.filter((_, index) => index !== preferredIndex)]
737
+ : [...targets];
738
+ const options = orderedTargets.map((target, index) => {
739
+ const label = describeTargetName(target.name, target.host);
740
+ const currentTag = index === 0 ? " (current)" : "";
741
+ return `[${index + 1}] ${label} -> ${target.remotePath}${currentTag}`;
742
+ });
743
+ const selectedOption = await ctx.ui.select("Select SFTP target", options);
744
+ if (!selectedOption) {
745
+ return null;
746
+ }
747
+ const selectedIndex = options.findIndex((option) => option === selectedOption);
748
+ if (selectedIndex < 0 || selectedIndex >= orderedTargets.length) {
749
+ return null;
750
+ }
751
+ return orderedTargets[selectedIndex] ?? null;
752
+ }
753
+ export function registerSftpCommands(pi) {
754
+ pi.on("tool_result", async (event, ctx) => {
755
+ if (event.isError) {
756
+ return;
757
+ }
758
+ let sftp;
759
+ if (event.toolName === "ApplyPatch" && isApplyPatchResultDetails(event.details)) {
760
+ sftp = await maybeSyncApplyPatchResult(ctx.cwd, event.details, ctx);
761
+ }
762
+ else if (event.toolName === "CodexApplyPatch" && isCodexApplyPatchResultDetails(event.details)) {
763
+ sftp = await maybeSyncCodexApplyPatchResult(ctx.cwd, event.details, ctx);
764
+ }
765
+ if (!sftp) {
766
+ return;
767
+ }
768
+ const baseDetails = event.details;
769
+ return {
770
+ details: {
771
+ ...baseDetails,
772
+ sftp,
773
+ },
774
+ };
775
+ });
776
+ pi.registerCommand("sftp", {
777
+ description: "Sync files with SFTP (.vscode/sftp.json)",
778
+ handler: async (args, ctx) => {
779
+ const parsed = parseSubcommand(args);
780
+ if (!parsed.cmd) {
781
+ notify(ctx, SFTP_USAGE, "warning");
782
+ return;
783
+ }
784
+ if ((parsed.cmd === "upload" || parsed.cmd === "download") && !parsed.path) {
785
+ notify(ctx, SFTP_USAGE, "warning");
786
+ return;
787
+ }
788
+ let targets;
789
+ try {
790
+ targets = await loadSftpTargets(ctx.cwd);
791
+ }
792
+ catch (error) {
793
+ notify(ctx, error.message, "error");
794
+ return;
795
+ }
796
+ if (targets.length === 0) {
797
+ notify(ctx, "No SFTP targets found in .vscode/sftp.json.", "warning");
798
+ return;
799
+ }
800
+ if (parsed.cmd === "select") {
801
+ if (!ctx.hasUI || !ctx.ui?.select) {
802
+ notify(ctx, `Interactive target selection requires UI.\nAvailable targets:\n${renderTargetList(targets)}`, "warning");
803
+ return;
804
+ }
805
+ const selected = await promptForTargetSelection(ctx, targets);
806
+ if (!selected) {
807
+ notify(ctx, "Target selection cancelled.", "warning");
808
+ return;
809
+ }
810
+ selectedTargetId = getTargetId(selected);
811
+ const label = describeTargetName(selected.name, selected.host);
812
+ notify(ctx, `SFTP target set to ${label}.`, "info");
813
+ return;
814
+ }
815
+ if (parsed.cmd === "target") {
816
+ if (!parsed.name) {
817
+ notify(ctx, `Missing target name.\n${SFTP_USAGE}`, "warning");
818
+ return;
819
+ }
820
+ const selected = pickTarget(targets, parsed.name);
821
+ if (!selected) {
822
+ notify(ctx, `Unknown target: ${parsed.name}`, "warning");
823
+ notify(ctx, `Available targets:\n${renderTargetList(targets)}`, "info");
824
+ return;
825
+ }
826
+ selectedTargetId = getTargetId(selected);
827
+ const label = describeTargetName(selected.name, selected.host);
828
+ notify(ctx, `SFTP target set to ${label}.`, "info");
829
+ return;
830
+ }
831
+ const explicit = selectedTargetId ? pickTarget(targets, selectedTargetId) : undefined;
832
+ if (selectedTargetId && !explicit) {
833
+ notify(ctx, `Selected target not found: ${selectedTargetId}`, "warning");
834
+ notify(ctx, `Available targets:\n${renderTargetList(targets)}`, "info");
835
+ return;
836
+ }
837
+ const selectedTargets = [explicit ?? targets[targets.length - 1]];
838
+ const targetPath = parsed.path;
839
+ if (!targetPath) {
840
+ notify(ctx, SFTP_USAGE, "warning");
841
+ return;
842
+ }
843
+ const interactiveTargets = selectedTargets.filter((target) => targetRequiresInteractiveAuth(target));
844
+ let otp = undefined;
845
+ const otpProvider = interactiveTargets.length > 0 && ctx.hasUI
846
+ ? async () => {
847
+ if (otp !== undefined) {
848
+ return otp;
849
+ }
850
+ const labels = interactiveTargets.map((target) => target.name ?? target.host);
851
+ otp = await promptForOtp(ctx, labels);
852
+ return otp;
853
+ }
854
+ : undefined;
855
+ const action = parsed.cmd;
856
+ const runAction = async (otpValue) => {
857
+ return action === "upload"
858
+ ? await uploadSftpPath({
859
+ cwd: ctx.cwd,
860
+ localPath: targetPath,
861
+ targets: selectedTargets,
862
+ otp: otpValue ?? undefined,
863
+ otpProvider,
864
+ requireExisting: false,
865
+ })
866
+ : await downloadSftpPath({
867
+ cwd: ctx.cwd,
868
+ localPath: targetPath,
869
+ targets: selectedTargets,
870
+ otp: otpValue ?? undefined,
871
+ otpProvider,
872
+ requireExisting: false,
873
+ });
874
+ };
875
+ let details = await runAction(otp);
876
+ if (interactiveTargets.length > 0 && otp === undefined && shouldRetryInteractiveAuth(details.errors)) {
877
+ if (!otpProvider) {
878
+ notify(ctx, "OTP input requires interactive UI.", "warning");
879
+ return;
880
+ }
881
+ otp = await otpProvider();
882
+ if (!otp) {
883
+ notify(ctx, "OTP input cancelled.", "warning");
884
+ return;
885
+ }
886
+ details = await runAction(otp);
887
+ }
888
+ const countLabel = action === "upload" ? "uploaded" : "downloaded";
889
+ const baseMessage = `${action} ${targetPath}: ${countLabel} ${details.uploaded} to ${formatTargets(details.targets)}`;
890
+ if (details.errors && details.errors.length > 0) {
891
+ notify(ctx, `${baseMessage}\nerrors: ${details.errors.join("; ")}`, "warning");
892
+ return;
893
+ }
894
+ notify(ctx, baseMessage, "info");
895
+ },
896
+ });
897
+ }