zerogterm 0.9.1-alpha.1 → 0.9.2-alpha.1

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.
@@ -0,0 +1,335 @@
1
+ // Pane link: relay one agent's finished reply into another agent's prompt.
2
+ //
3
+ // This module is the decision logic only. It owns no sockets and no PTYs: the hook
4
+ // server feeds it what the agents report (a prompt was submitted, a turn finished, the
5
+ // agent needs attention, the session ended) and it answers by asking for text to be
6
+ // delivered to a session. Keeping it that small is what lets every safety rule below be
7
+ // exercised in a unit test with a fake clock.
8
+ //
9
+ // Why hook events and not terminal output: a throwaway prototype that guessed turn ends
10
+ // from a quiet timer and scraped the reply off the screen declared turns finished while
11
+ // the agent was still thinking and forwarded spinner glyphs and echoed prompts. The Stop
12
+ // hook reports the turn end exactly and carries the final message verbatim.
13
+ //
14
+ // Safety rules enforced here (CONTEXT.md: no autonomous agent loops without an explicit
15
+ // design):
16
+ // - A link exists only because the user asked for it, between two agents ZeroG itself
17
+ // launched, and it ends on the user's say-so, on the turn cap, or when either agent exits.
18
+ // - Text is only delivered to an agent that is idle. If the partner is busy the link
19
+ // pauses and holds the message; it never types into a running turn.
20
+ // - Relayed text is untrusted input to the receiving agent. It is stripped of control
21
+ // characters (an ESC would let a reply close the bracketed paste early and have the rest
22
+ // typed as keystrokes), size-capped, and labelled as coming from another agent.
23
+ // - A relay that never gets an answer (permission prompt left open, Esc interrupt, API
24
+ // error: none of which fire Stop) pauses after a stall timeout instead of waiting forever.
25
+ export const DEFAULT_RELAY_CAP = 6;
26
+ export const MAX_RELAY_CAP = 50;
27
+ export const DEFAULT_STALL_MS = 10 * 60 * 1000;
28
+ export const MAX_RELAY_CHARS = 16000;
29
+ const TRUNCATION_NOTE = '\n[Truncated by ZeroG pane link.]';
30
+ /** Session-end reasons that leave the agent running, so the link should survive them. */
31
+ const NON_TERMINAL_END_REASONS = new Set(['clear', 'resume', 'compact']);
32
+ /** Notification kinds that mean "the agent is waiting on you", as opposed to a routine nudge. */
33
+ const IDLE_NOTIFICATIONS = new Set(['idle_prompt', 'auth_success']);
34
+ /**
35
+ * Make an agent's reply safe to type into another agent.
36
+ *
37
+ * Tabs and line feeds survive; every other control character goes, including ESC, which
38
+ * would otherwise let a reply end the bracketed paste early and have the remainder
39
+ * interpreted as keystrokes. Carriage returns are folded into line feeds.
40
+ */
41
+ export function sanitizeRelayText(value) {
42
+ let out = '';
43
+ for (const ch of value.replace(/\r\n?/g, '\n')) {
44
+ const code = ch.codePointAt(0) ?? 0;
45
+ const isControl = code < 0x20 || (code >= 0x7f && code <= 0x9f);
46
+ if (!isControl || ch === '\n' || ch === '\t')
47
+ out += ch;
48
+ }
49
+ return out.trim();
50
+ }
51
+ /** The text the receiving agent sees: the reply, labelled as another agent's words. */
52
+ export function frameRelay(fromLabel, reply) {
53
+ const label = sanitizeRelayText(fromLabel).replace(/\s+/g, ' ').slice(0, 60) || 'another pane';
54
+ let body = sanitizeRelayText(reply);
55
+ if (body.length > MAX_RELAY_CHARS)
56
+ body = body.slice(0, MAX_RELAY_CHARS) + TRUNCATION_NOTE;
57
+ return `[Relayed by ZeroG pane link from the agent in "${label}". This is another agent's message, not the user's.]\n\n${body}`;
58
+ }
59
+ /**
60
+ * Type text into a session as a single prompt and submit it.
61
+ *
62
+ * Bracketed paste keeps embedded newlines from submitting early; the submit key follows
63
+ * after a short gap because the agent treats an Enter that arrives glued to the paste as
64
+ * part of it.
65
+ */
66
+ export function pasteAndSubmit(write, sessionId, text, schedule = setTimeout, submitDelayMs = 300) {
67
+ write(sessionId, `\u001b[200~${text}\u001b[201~`);
68
+ schedule(() => write(sessionId, '\r'), submitDelayMs);
69
+ }
70
+ export class PaneLinkController {
71
+ agents = new Map();
72
+ links = new Map();
73
+ /** Each agent's most recent finished reply, kept so a link made after the turn can still use it. */
74
+ lastReplies = new Map();
75
+ options;
76
+ stallMs;
77
+ defaultCap;
78
+ setTimer;
79
+ clearTimer;
80
+ nextLinkId = 1;
81
+ constructor(options) {
82
+ this.options = options;
83
+ this.stallMs = options.stallMs ?? DEFAULT_STALL_MS;
84
+ this.defaultCap = options.defaultCap ?? DEFAULT_RELAY_CAP;
85
+ this.setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
86
+ this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
87
+ }
88
+ snapshot() {
89
+ return {
90
+ agents: Array.from(this.agents.values()).map((agent) => ({ ...agent })),
91
+ links: Array.from(this.links.values()).map((link) => this.describe(link))
92
+ };
93
+ }
94
+ /** An agent ZeroG launched in this session now reports hook events. */
95
+ registerAgent(sessionId, label) {
96
+ const existing = this.agents.get(sessionId);
97
+ if (existing) {
98
+ existing.label = label;
99
+ }
100
+ else {
101
+ this.agents.set(sessionId, { sessionId, label, phase: 'idle', needsAttention: false, hasReply: false });
102
+ }
103
+ this.emit();
104
+ }
105
+ /** The session is gone (pane closed, agent unregistered): end its link and forget it. */
106
+ unregisterAgent(sessionId) {
107
+ const agent = this.agents.get(sessionId);
108
+ if (!agent)
109
+ return;
110
+ if (agent.linkId)
111
+ this.breakLink(agent.linkId, `The agent in "${agent.label}" exited.`);
112
+ this.agents.delete(sessionId);
113
+ this.lastReplies.delete(sessionId);
114
+ this.emit();
115
+ }
116
+ /** Link two idle agents. Throws a user-readable message when the link is not allowed. */
117
+ link(aSessionId, bSessionId, cap) {
118
+ if (aSessionId === bSessionId)
119
+ throw new Error('Choose two different panes to link.');
120
+ const a = this.agents.get(aSessionId);
121
+ const b = this.agents.get(bSessionId);
122
+ if (!a || !b)
123
+ throw new Error('Both panes must be running an agent started with "Start linked agent".');
124
+ if (a.linkId || b.linkId)
125
+ throw new Error('A pane can only be in one link at a time. Break the existing link first.');
126
+ if (a.phase !== 'idle' || b.phase !== 'idle')
127
+ throw new Error('Wait for both agents to finish their current turn before linking.');
128
+ // A broken link stays listed so its reason can be read; starting a new one retires it.
129
+ for (const stale of Array.from(this.links.values())) {
130
+ if (stale.status === 'broken' && [stale.a, stale.b].some((id) => id === aSessionId || id === bSessionId))
131
+ this.links.delete(stale.id);
132
+ }
133
+ const relays = cap ?? this.defaultCap;
134
+ if (!Number.isInteger(relays) || relays < 1 || relays > MAX_RELAY_CAP) {
135
+ throw new Error(`The relay limit must be a whole number from 1 to ${MAX_RELAY_CAP}.`);
136
+ }
137
+ const link = { id: `link-${this.nextLinkId++}`, a: aSessionId, b: bSessionId, status: 'active', turns: 0, cap: relays };
138
+ this.links.set(link.id, link);
139
+ a.linkId = link.id;
140
+ b.linkId = link.id;
141
+ this.emit();
142
+ return this.describe(link);
143
+ }
144
+ /** The user breaks the link. Removes it entirely. */
145
+ unlink(linkId) {
146
+ const link = this.links.get(linkId);
147
+ if (!link)
148
+ return;
149
+ this.dropLink(link);
150
+ this.emit();
151
+ }
152
+ /** Re-arm a paused link, delivering the held reply if there is one and the partner is ready. */
153
+ resume(linkId) {
154
+ const link = this.links.get(linkId);
155
+ if (!link)
156
+ throw new Error('That link no longer exists.');
157
+ if (link.status !== 'paused')
158
+ return this.describe(link);
159
+ const held = link.held;
160
+ link.status = 'active';
161
+ link.reason = undefined;
162
+ link.waitingOn = undefined;
163
+ if (held) {
164
+ link.held = undefined;
165
+ this.relay(link, held.from, held.text);
166
+ }
167
+ this.emit();
168
+ return this.describe(link);
169
+ }
170
+ /**
171
+ * Relay an agent's most recent reply now. For a link made after the agent's turn
172
+ * finished (nothing else would ever trigger it) and for resuming by hand.
173
+ */
174
+ relayLast(linkId, fromSessionId) {
175
+ const link = this.links.get(linkId);
176
+ if (!link)
177
+ throw new Error('That link no longer exists.');
178
+ if (link.status === 'broken')
179
+ throw new Error('That link has ended. Link the panes again first.');
180
+ if (fromSessionId !== link.a && fromSessionId !== link.b)
181
+ throw new Error('That pane is not part of this link.');
182
+ const reply = this.lastReplies.get(fromSessionId);
183
+ if (!reply)
184
+ throw new Error('That agent has not finished a reply yet.');
185
+ if (link.waitingOn)
186
+ throw new Error('Wait for the agent that is already working on a relayed message.');
187
+ link.status = 'active';
188
+ link.reason = undefined;
189
+ link.held = undefined;
190
+ this.relay(link, fromSessionId, reply);
191
+ this.emit();
192
+ return this.describe(link);
193
+ }
194
+ /** Feed an event reported by an agent's hooks. */
195
+ handle(event) {
196
+ const agent = this.agents.get(event.sessionId);
197
+ if (!agent)
198
+ return;
199
+ const link = agent.linkId ? this.links.get(agent.linkId) : undefined;
200
+ switch (event.event) {
201
+ case 'UserPromptSubmit':
202
+ agent.phase = 'busy';
203
+ agent.needsAttention = false;
204
+ break;
205
+ case 'Notification':
206
+ if (!IDLE_NOTIFICATIONS.has(event.notificationType ?? ''))
207
+ agent.needsAttention = true;
208
+ break;
209
+ case 'SessionEnd':
210
+ if (NON_TERMINAL_END_REASONS.has(event.reason ?? ''))
211
+ break;
212
+ if (link)
213
+ this.breakLink(link.id, `The agent in "${agent.label}" exited.`);
214
+ this.agents.delete(agent.sessionId);
215
+ break;
216
+ case 'Stop':
217
+ agent.phase = 'idle';
218
+ agent.needsAttention = false;
219
+ if (sanitizeRelayText(event.message ?? '')) {
220
+ this.lastReplies.set(agent.sessionId, event.message ?? '');
221
+ agent.hasReply = true;
222
+ }
223
+ if (link)
224
+ this.onTurnFinished(link, agent, event.message ?? '');
225
+ break;
226
+ }
227
+ this.emit();
228
+ }
229
+ onTurnFinished(link, agent, message) {
230
+ if (link.status !== 'active')
231
+ return;
232
+ // We are waiting on the other agent, so this turn is not one we should relay (the user
233
+ // is driving this pane by hand). The expected answer will still arrive from the other side.
234
+ if (link.waitingOn && link.waitingOn !== agent.sessionId)
235
+ return;
236
+ this.cancelStall(link);
237
+ link.waitingOn = undefined;
238
+ if (!sanitizeRelayText(message)) {
239
+ this.pause(link, `The agent in "${agent.label}" finished without a reply to relay.`);
240
+ return;
241
+ }
242
+ if (link.turns >= link.cap) {
243
+ this.breakLink(link.id, `Reached the limit of ${link.cap} relays.`);
244
+ return;
245
+ }
246
+ this.relay(link, agent.sessionId, message);
247
+ }
248
+ relay(link, fromSessionId, message) {
249
+ const from = this.agents.get(fromSessionId);
250
+ const toSessionId = link.a === fromSessionId ? link.b : link.a;
251
+ const to = this.agents.get(toSessionId);
252
+ if (!from || !to) {
253
+ this.breakLink(link.id, 'One of the linked agents is gone.');
254
+ return;
255
+ }
256
+ if (link.turns >= link.cap) {
257
+ this.breakLink(link.id, `Reached the limit of ${link.cap} relays.`);
258
+ return;
259
+ }
260
+ if (to.phase !== 'idle' || to.needsAttention) {
261
+ link.held = { from: fromSessionId, text: message };
262
+ this.pause(link, to.needsAttention
263
+ ? `The agent in "${to.label}" is waiting for you. Answer it, then resume.`
264
+ : `The agent in "${to.label}" is busy. Resume once it finishes.`);
265
+ return;
266
+ }
267
+ to.phase = 'busy';
268
+ link.turns += 1;
269
+ link.waitingOn = toSessionId;
270
+ this.options.deliver(toSessionId, frameRelay(from.label, message));
271
+ this.armStall(link, to.label);
272
+ }
273
+ pause(link, reason) {
274
+ this.cancelStall(link);
275
+ link.status = 'paused';
276
+ link.reason = reason;
277
+ }
278
+ breakLink(linkId, reason) {
279
+ const link = this.links.get(linkId);
280
+ if (!link)
281
+ return;
282
+ this.cancelStall(link);
283
+ link.status = 'broken';
284
+ link.reason = reason;
285
+ link.waitingOn = undefined;
286
+ link.held = undefined;
287
+ for (const sessionId of [link.a, link.b]) {
288
+ const agent = this.agents.get(sessionId);
289
+ if (agent?.linkId === link.id)
290
+ agent.linkId = undefined;
291
+ }
292
+ // A broken link stays listed so the user can read why it ended; it no longer holds either pane.
293
+ }
294
+ dropLink(link) {
295
+ this.cancelStall(link);
296
+ for (const sessionId of [link.a, link.b]) {
297
+ const agent = this.agents.get(sessionId);
298
+ if (agent?.linkId === link.id)
299
+ agent.linkId = undefined;
300
+ }
301
+ this.links.delete(link.id);
302
+ }
303
+ armStall(link, waitingLabel) {
304
+ this.cancelStall(link);
305
+ link.stallTimer = this.setTimer(() => {
306
+ link.stallTimer = undefined;
307
+ if (link.status !== 'active')
308
+ return;
309
+ this.pause(link, `No reply from "${waitingLabel}". It may be waiting for approval or was interrupted.`);
310
+ this.emit();
311
+ }, this.stallMs);
312
+ }
313
+ cancelStall(link) {
314
+ if (link.stallTimer !== undefined) {
315
+ this.clearTimer(link.stallTimer);
316
+ link.stallTimer = undefined;
317
+ }
318
+ }
319
+ describe(link) {
320
+ return {
321
+ id: link.id,
322
+ a: link.a,
323
+ b: link.b,
324
+ status: link.status,
325
+ turns: link.turns,
326
+ cap: link.cap,
327
+ reason: link.reason,
328
+ waitingOn: link.waitingOn,
329
+ holding: link.held !== undefined
330
+ };
331
+ }
332
+ emit() {
333
+ this.options.onChange?.(this.snapshot());
334
+ }
335
+ }
@@ -14,6 +14,8 @@ const api = {
14
14
  answerForwardPrompt: (id, answer) => ipcRenderer.invoke('forwards:answerPrompt', id, answer),
15
15
  loadForwards: () => ipcRenderer.invoke('forwards:load'),
16
16
  saveForwards: (file) => ipcRenderer.invoke('forwards:save', file),
17
+ loadHostLabels: () => ipcRenderer.invoke('hostLabels:load'),
18
+ saveHostLabels: (file) => ipcRenderer.invoke('hostLabels:save', file),
17
19
  onForwardEvent: (callback) => {
18
20
  const listener = (_event, payload) => callback(payload);
19
21
  ipcRenderer.on('forwards:event', listener);
@@ -49,6 +51,17 @@ const api = {
49
51
  ipcRenderer.on('mcp:execution', listener);
50
52
  return () => ipcRenderer.removeListener('mcp:execution', listener);
51
53
  },
54
+ launchLinkedAgent: (sessionId, agentCommand) => ipcRenderer.invoke('paneLinks:launchAgent', sessionId, agentCommand),
55
+ listPaneLinks: () => ipcRenderer.invoke('paneLinks:list'),
56
+ linkPanes: (a, b, cap) => ipcRenderer.invoke('paneLinks:link', a, b, cap),
57
+ relayLastReply: (linkId, fromSessionId) => ipcRenderer.invoke('paneLinks:relayLast', linkId, fromSessionId),
58
+ unlinkPanes: (linkId) => ipcRenderer.invoke('paneLinks:unlink', linkId),
59
+ resumePaneLink: (linkId) => ipcRenderer.invoke('paneLinks:resume', linkId),
60
+ onPaneLinks: (callback) => {
61
+ const listener = (_event, snapshot) => callback(snapshot);
62
+ ipcRenderer.on('paneLinks:changed', listener);
63
+ return () => ipcRenderer.removeListener('paneLinks:changed', listener);
64
+ },
52
65
  listBackends: () => ipcRenderer.invoke('sessions:backends'),
53
66
  listWslDistributions: () => ipcRenderer.invoke('sessions:wslDistributions'),
54
67
  createLocalSession: (request) => ipcRenderer.invoke('sessions:createLocal', request),
@@ -86,6 +99,8 @@ const api = {
86
99
  createLocalDirectory: (path) => ipcRenderer.invoke('fs:mkdirLocal', path),
87
100
  renameLocalEntry: (from, to) => ipcRenderer.invoke('fs:renameLocal', from, to),
88
101
  removeLocalEntry: (path, kind) => ipcRenderer.invoke('fs:removeLocal', path, kind),
102
+ readLocalFile: (path) => ipcRenderer.invoke('fs:readLocalFile', path),
103
+ writeLocalFile: (path, text, expectedMtimeMs, overwrite) => ipcRenderer.invoke('fs:writeLocalFile', path, text, expectedMtimeMs, overwrite),
89
104
  sftpOpen: (target, cwd) => ipcRenderer.invoke('sftp:open', target, cwd),
90
105
  sftpList: (sessionId, path) => ipcRenderer.invoke('sftp:list', sessionId, path),
91
106
  sftpMkdir: (sessionId, path) => ipcRenderer.invoke('sftp:mkdir', sessionId, path),
@@ -94,6 +109,8 @@ const api = {
94
109
  sftpUpload: (sessionId, localPath, remoteDir) => ipcRenderer.invoke('sftp:upload', sessionId, localPath, remoteDir),
95
110
  sftpDownload: (sessionId, remotePath, localDir, kind) => ipcRenderer.invoke('sftp:download', sessionId, remotePath, localDir, kind),
96
111
  sftpAnswerPrompt: (sessionId, answer) => ipcRenderer.invoke('sftp:answerPrompt', sessionId, answer),
112
+ sftpReadFile: (sessionId, path) => ipcRenderer.invoke('sftp:readFile', sessionId, path),
113
+ sftpWriteFile: (sessionId, path, text, opened, overwrite) => ipcRenderer.invoke('sftp:writeFile', sessionId, path, text, opened, overwrite),
97
114
  sftpClose: (sessionId) => ipcRenderer.invoke('sftp:close', sessionId),
98
115
  onSftpEvent: (callback) => {
99
116
  const listener = (_event, payload) => callback(payload);
@@ -0,0 +1,97 @@
1
+ // Reading and writing one text file on an SSH host, for the editor.
2
+ //
3
+ // sftp has no "send me the bytes" command, only get and put, so a read is a
4
+ // download to a private temp directory and a save is an upload from one. Each is
5
+ // cleaned up whatever happens: a copy of someone's `.env` left in a temp folder
6
+ // is the worst thing this module could do.
7
+ //
8
+ // A save is checked against the host the same way a local one is checked against
9
+ // the disk, but by *content*: sftp's listing times are only minute-accurate, so
10
+ // the file is fetched again and compared with what the editor opened. That costs
11
+ // a second transfer and cannot be wrong.
12
+ import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { EDIT_CONFLICT_MESSAGE, MAX_EDIT_BYTES } from '../shared/editing.js';
16
+ import { baseName, parentRemote } from '../shared/files.js';
17
+ import { checkEditableSize, decodeEditable, megabytes } from './editable.js';
18
+ import { isSafeRemotePath } from './sftp-protocol.js';
19
+ async function withTempDir(run) {
20
+ // mkdtemp makes the directory private to this user where the platform has
21
+ // such a thing.
22
+ const dir = await mkdtemp(join(tmpdir(), 'zerog-remote-edit-'));
23
+ try {
24
+ return await run(dir);
25
+ }
26
+ finally {
27
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
28
+ }
29
+ }
30
+ function requireEditablePath(path) {
31
+ if (!path.startsWith('/'))
32
+ throw new Error('Only absolute remote paths can be edited.');
33
+ if (!isSafeRemotePath(path)) {
34
+ throw new Error('That file name has a quote, backslash, wildcard or control character in it, which this editor cannot open over SSH yet.');
35
+ }
36
+ }
37
+ /** The file's bytes, fetched into a temp directory and read back out of it. */
38
+ async function fetch(transport, sessionId, path) {
39
+ return withTempDir(async (dir) => {
40
+ await transport.download(sessionId, path, dir, 'file');
41
+ const local = join(dir, baseName(path));
42
+ const info = await stat(local);
43
+ // The listing could not vouch for the size of a symlink, so the real one is
44
+ // checked here too — after the fact, but before the bytes are read.
45
+ checkEditableSize(info.size);
46
+ return readFile(local);
47
+ });
48
+ }
49
+ /**
50
+ * Read a remote file as text, or say why it cannot be edited.
51
+ *
52
+ * The size is taken from a listing first, so a large file is refused before it
53
+ * is downloaded rather than after.
54
+ */
55
+ export async function readRemoteFile(transport, sessionId, path) {
56
+ requireEditablePath(path);
57
+ const listing = await transport.list(sessionId, parentRemote(path));
58
+ const entry = listing.entries.find((candidate) => candidate.name === baseName(path));
59
+ if (!entry)
60
+ throw new Error('That file does not exist on the host.');
61
+ if (entry.kind === 'directory')
62
+ throw new Error('That is not a file.');
63
+ // A symlink's listed size is the link's own, so it is checked after the fetch.
64
+ if (entry.kind === 'file')
65
+ checkEditableSize(entry.size);
66
+ const bytes = await fetch(transport, sessionId, path);
67
+ return { path, text: decodeEditable(bytes), size: bytes.length };
68
+ }
69
+ /**
70
+ * Save the editor's text to a remote file.
71
+ *
72
+ * `opened` is the exact text the editor read, and the save is refused if the host
73
+ * now holds something else. `overwrite` is the user saying they have seen that.
74
+ *
75
+ * The upload is a plain `put` with no `-p`: that leaves an existing file's
76
+ * permissions alone, where preserving the temp file's would reset them — a
77
+ * `.env` that was 600 must not come back world-readable.
78
+ */
79
+ export async function writeRemoteFile(transport, sessionId, path, text, opened, overwrite = false) {
80
+ requireEditablePath(path);
81
+ if (Buffer.byteLength(text, 'utf8') > MAX_EDIT_BYTES) {
82
+ throw new Error(`That is more than ${megabytes(MAX_EDIT_BYTES)}, which is more than the editor saves.`);
83
+ }
84
+ if (!overwrite) {
85
+ // Not the same failure as "changed": a file that has gone is reported as
86
+ // gone, by the transfer's own error, rather than as a conflict.
87
+ const now = decodeEditable(await fetch(transport, sessionId, path));
88
+ if (now !== opened)
89
+ throw new Error(EDIT_CONFLICT_MESSAGE);
90
+ }
91
+ await withTempDir(async (dir) => {
92
+ const local = join(dir, baseName(path));
93
+ await writeFile(local, text, 'utf8');
94
+ await transport.putFile(sessionId, local, path);
95
+ });
96
+ return { size: Buffer.byteLength(text, 'utf8') };
97
+ }
@@ -196,6 +196,18 @@ export class SftpService {
196
196
  const command = `put -p ${quoteLocalPath(localPath)} ${quoteRemotePath(destination)}`;
197
197
  await this.enqueue(connection, () => this.checked(connection, command, TRANSFER_IDLE_MS, true));
198
198
  }
199
+ /**
200
+ * Upload one file to an exact remote path.
201
+ *
202
+ * No `-p`, unlike upload(): that would stamp the remote file with the local
203
+ * file's permissions, and the editor's local file is a temp copy. Without it an
204
+ * existing remote file keeps its own mode — a 600 `.env` stays 600.
205
+ */
206
+ async putFile(sessionId, localPath, remotePath) {
207
+ const connection = this.get(sessionId);
208
+ const command = `put ${quoteLocalPath(localPath)} ${quoteRemotePath(remotePath)}`;
209
+ await this.enqueue(connection, () => this.checked(connection, command, TRANSFER_IDLE_MS, true));
210
+ }
199
211
  async download(sessionId, remotePath, localDir, kind) {
200
212
  const connection = this.get(sessionId);
201
213
  const name = baseName(remotePath);
@@ -129,6 +129,13 @@ function normalizeView(value, memberIds) {
129
129
  const browsers = normalizeBrowsers(item.browsers, memberIds);
130
130
  if (browsers)
131
131
  view.browsers = browsers;
132
+ // Slot order matters, so the list is kept as given, minus anything the
133
+ // workspace does not hold and any repeat.
134
+ const visible = Array.isArray(item.visiblePanes)
135
+ ? [...new Set(item.visiblePanes.map(member).filter((id) => id !== undefined))].slice(0, 2)
136
+ : [];
137
+ if (visible.length)
138
+ view.visiblePanes = visible;
132
139
  return view;
133
140
  }
134
141
  /**
@@ -0,0 +1,11 @@
1
+ // What the built-in editor and the main process both need to agree on.
2
+ /** The largest file the editor will open or save. Past this it is not a text file to edit by hand. */
3
+ export const MAX_EDIT_BYTES = 1024 * 1024;
4
+ /**
5
+ * What a refused save says when the file changed after it was loaded.
6
+ *
7
+ * The renderer tells this apart from any other failure by the text, because an
8
+ * IPC rejection carries nothing but its message — and the two deserve different
9
+ * responses: a conflict offers to overwrite or reload, a failure just says so.
10
+ */
11
+ export const EDIT_CONFLICT_MESSAGE = 'This file changed on disk after it was opened.';
@@ -11,6 +11,17 @@ export function sortEntries(entries) {
11
11
  return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
12
12
  });
13
13
  }
14
+ /**
15
+ * Is this entry a dotfile?
16
+ *
17
+ * Neither backend reports a platform "hidden" attribute — the SFTP side is a
18
+ * parsed `ls` line, and Node's `fs.Stats` does not expose the Windows bit
19
+ * either — so the leading dot, the convention every listed shell already
20
+ * follows, is what "hidden" means here.
21
+ */
22
+ export function isHiddenName(name) {
23
+ return name.startsWith('.');
24
+ }
14
25
  /** Join a remote directory and a child name. Remote paths are always POSIX. */
15
26
  export function joinRemote(dir, name) {
16
27
  if (name.startsWith('/'))