@phenx-inc/ctlsurf 0.10.0 → 0.12.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/out/headless/index.mjs +111 -5
- package/out/headless/index.mjs.map +3 -3
- package/out/main/index.js +357 -28
- package/out/preload/index.js +9 -0
- package/out/renderer/assets/{cssMode-BbCVOTaJ.js → cssMode-R8fTFLae.js} +3 -3
- package/out/renderer/assets/{freemarker2-DryLywQv.js → freemarker2-CyjWzWoP.js} +1 -1
- package/out/renderer/assets/{handlebars-CdUMtSdr.js → handlebars-CrhCGIRM.js} +1 -1
- package/out/renderer/assets/{html-Cwkf2GMH.js → html-Cc3dPNZr.js} +1 -1
- package/out/renderer/assets/{htmlMode-C7PzhXxH.js → htmlMode-BWtG6ywk.js} +3 -3
- package/out/renderer/assets/{index-BIceGcz3.css → index-BL9njXIL.css} +206 -46
- package/out/renderer/assets/{index-Dg1vMspf.js → index-kkG56aKv.js} +529 -156
- package/out/renderer/assets/{javascript-Cy7ROXJl.js → javascript-C8T_SdLC.js} +2 -2
- package/out/renderer/assets/{jsonMode-BtZtt3QW.js → jsonMode-D1zR4VVU.js} +3 -3
- package/out/renderer/assets/{liquid-xReAQeYs.js → liquid-COSMe9Uf.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-Dmx0L0Bw.js → lspLanguageFeatures-CXGfrtzl.js} +1 -1
- package/out/renderer/assets/{mdx-YMNFO1Zz.js → mdx-RfS44T-L.js} +1 -1
- package/out/renderer/assets/{python-BExaDVxv.js → python-DFSWKHUu.js} +1 -1
- package/out/renderer/assets/{razor-HL8ANmQ4.js → razor-DtH2_uvo.js} +1 -1
- package/out/renderer/assets/{tsMode-Dqd8vdLE.js → tsMode-BYIQ-S9Z.js} +1 -1
- package/out/renderer/assets/{typescript-Dysz1PTX.js → typescript-HhQUr1Rb.js} +1 -1
- package/out/renderer/assets/{xml-BHp5Z9nc.js → xml-KuhgdKfD.js} +1 -1
- package/out/renderer/assets/{yaml-CPDHxVup.js → yaml-W_kzCFH7.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/agents.ts +9 -1
- package/src/main/ctlsurfApi.ts +7 -0
- package/src/main/index.ts +118 -34
- package/src/main/orchestrator.ts +105 -4
- package/src/main/ssh.ts +348 -0
- package/src/preload/index.ts +26 -0
- package/src/renderer/App.tsx +29 -7
- package/src/renderer/components/AgentPicker.tsx +18 -0
- package/src/renderer/components/ProjectPicker.tsx +121 -0
- package/src/renderer/components/RemoteConnect.tsx +315 -0
- package/src/renderer/components/TicketPanel.tsx +20 -25
- package/src/renderer/styles.css +206 -46
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { conf as conf$1, language as language$1 } from "./typescript-
|
|
2
|
-
import "./index-
|
|
1
|
+
import { conf as conf$1, language as language$1 } from "./typescript-HhQUr1Rb.js";
|
|
2
|
+
import "./index-kkG56aKv.js";
|
|
3
3
|
const conf = conf$1;
|
|
4
4
|
const language = {
|
|
5
5
|
// Set defaultToken to invalid to see what you do not tokenize yet
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { c as createWebWorker, l as languages, e as editor } from "./index-
|
|
2
|
-
import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-
|
|
3
|
-
import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-
|
|
1
|
+
import { c as createWebWorker, l as languages, e as editor } from "./index-kkG56aKv.js";
|
|
2
|
+
import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-CXGfrtzl.js";
|
|
3
|
+
import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-CXGfrtzl.js";
|
|
4
4
|
const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
|
|
5
5
|
class WorkerManager {
|
|
6
6
|
constructor(defaults) {
|
package/out/renderer/assets/{lspLanguageFeatures-Dmx0L0Bw.js → lspLanguageFeatures-CXGfrtzl.js}
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-
|
|
1
|
+
import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-kkG56aKv.js";
|
|
2
2
|
var DocumentUri;
|
|
3
3
|
(function(DocumentUri2) {
|
|
4
4
|
function is(value) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-
|
|
1
|
+
import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-kkG56aKv.js";
|
|
2
2
|
class WorkerManager {
|
|
3
3
|
constructor(_modeId, _defaults) {
|
|
4
4
|
this._modeId = _modeId;
|
package/out/renderer/index.html
CHANGED
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
color: #565f89;
|
|
50
50
|
}
|
|
51
51
|
</style>
|
|
52
|
-
<script type="module" crossorigin src="./assets/index-
|
|
53
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
52
|
+
<script type="module" crossorigin src="./assets/index-kkG56aKv.js"></script>
|
|
53
|
+
<link rel="stylesheet" crossorigin href="./assets/index-BL9njXIL.css">
|
|
54
54
|
</head>
|
|
55
55
|
<body>
|
|
56
56
|
<div id="boot-splash">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phenx-inc/ctlsurf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
|
|
5
5
|
"main": "out/main/index.js",
|
|
6
6
|
"bin": {
|
package/src/main/agents.ts
CHANGED
|
@@ -76,6 +76,14 @@ export function getDefaultAgent(): AgentConfig {
|
|
|
76
76
|
return getAvailableAgents()[0]
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// A remote agent runs over SSH (see ssh.ts). Its byte stream still flows
|
|
80
|
+
// through the PTY — so the always-on agent-state monitor drives the dock
|
|
81
|
+
// indicator — but its session files live on the remote box, so it must stay out
|
|
82
|
+
// of local-only plumbing (transcript tailing, the ctlsurf worker socket).
|
|
83
|
+
export function isRemoteAgent(agent: AgentConfig): boolean {
|
|
84
|
+
return agent.id.startsWith('remote-')
|
|
85
|
+
}
|
|
86
|
+
|
|
79
87
|
export function isCodingAgent(agent: AgentConfig): boolean {
|
|
80
|
-
return agent.id !== 'shell'
|
|
88
|
+
return agent.id !== 'shell' && !isRemoteAgent(agent)
|
|
81
89
|
}
|
package/src/main/ctlsurfApi.ts
CHANGED
|
@@ -90,6 +90,13 @@ export class CtlsurfApi {
|
|
|
90
90
|
return this.request('POST', '/folders', params)
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// All folders (projects) the API key can see. Used to let a remote session
|
|
94
|
+
// pick which ctlsurf project to bind to.
|
|
95
|
+
async listFolders(): Promise<Array<{ id: string; name: string; root_path?: string; git_remote?: string }>> {
|
|
96
|
+
const folders = await this.request('GET', '/folders')
|
|
97
|
+
return Array.isArray(folders) ? folders : []
|
|
98
|
+
}
|
|
99
|
+
|
|
93
100
|
// ─── Workers ────────────────────────────────────────
|
|
94
101
|
|
|
95
102
|
async getAuthCode(): Promise<{ code: string }> {
|
package/src/main/index.ts
CHANGED
|
@@ -25,6 +25,9 @@ import { AgentConfig, getDefaultAgent, getAvailableAgents } from './agents'
|
|
|
25
25
|
import { getSettingsDir } from './settingsDir'
|
|
26
26
|
import { Orchestrator } from './orchestrator'
|
|
27
27
|
import { AttentionController } from './agentState'
|
|
28
|
+
import { listSshHosts, getRecentHosts, addRecentHost, probeRemote, buildRemoteAgent, type RemoteAction } from './ssh'
|
|
29
|
+
|
|
30
|
+
const SETTINGS_DIR = getSettingsDir(true)
|
|
28
31
|
|
|
29
32
|
let mainWindow: BrowserWindow | null = null
|
|
30
33
|
|
|
@@ -51,12 +54,12 @@ function getProjectAbbrev(cwdPath: string): string {
|
|
|
51
54
|
return word.slice(0, 2).toUpperCase()
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
// Draw a 32x32 filled circle overlay. `
|
|
55
|
-
// blue and the "
|
|
56
|
-
function createDotOverlay(
|
|
57
|
+
// Draw a 32x32 filled circle overlay (Windows taskbar). `ready` switches between
|
|
58
|
+
// the project-badge blue and the green "ready for you" dot.
|
|
59
|
+
function createDotOverlay(ready: boolean): Electron.NativeImage {
|
|
57
60
|
const size = 32
|
|
58
61
|
const canvas = Buffer.alloc(size * size * 4, 0) // RGBA
|
|
59
|
-
const [r, g, b] =
|
|
62
|
+
const [r, g, b] = ready ? [52, 199, 89] : [122, 162, 247]
|
|
60
63
|
for (let y = 0; y < size; y++) {
|
|
61
64
|
for (let x = 0; x < size; x++) {
|
|
62
65
|
const cx = x - size / 2
|
|
@@ -73,6 +76,43 @@ function createDotOverlay(red: boolean): Electron.NativeImage {
|
|
|
73
76
|
return nativeImage.createFromBuffer(canvas, { width: size, height: size })
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
// The plain dock icon (macOS), loaded once at startup. Reused to composite the
|
|
80
|
+
// green "ready" dot on top, and to restore the clean icon when it clears.
|
|
81
|
+
let baseDockIcon: Electron.NativeImage | null = null
|
|
82
|
+
|
|
83
|
+
// Composite a green "ready for you" dot (with a soft dark ring for contrast) into
|
|
84
|
+
// the lower-right of the dock icon. Reads/writes the bitmap as a matched
|
|
85
|
+
// toBitmap()/createFromBitmap() pair, which share the same (BGRA) byte order.
|
|
86
|
+
function dockIconWithReadyDot(base: Electron.NativeImage): Electron.NativeImage {
|
|
87
|
+
const { width, height } = base.getSize()
|
|
88
|
+
if (!width || !height) return base
|
|
89
|
+
const bmp = base.toBitmap()
|
|
90
|
+
const r = Math.round(Math.min(width, height) * 0.17) // dot radius
|
|
91
|
+
const ring = Math.max(2, Math.round(r * 0.22)) // ring thickness
|
|
92
|
+
const cx = width - r - Math.round(width * 0.05)
|
|
93
|
+
const cy = height - r - Math.round(height * 0.05)
|
|
94
|
+
const put = (x: number, y: number, b: number, g: number, rr: number) => {
|
|
95
|
+
const idx = (y * width + x) * 4
|
|
96
|
+
bmp[idx] = b; bmp[idx + 1] = g; bmp[idx + 2] = rr; bmp[idx + 3] = 255
|
|
97
|
+
}
|
|
98
|
+
for (let y = cy - r - ring; y <= cy + r + ring; y++) {
|
|
99
|
+
if (y < 0 || y >= height) continue
|
|
100
|
+
for (let x = cx - r - ring; x <= cx + r + ring; x++) {
|
|
101
|
+
if (x < 0 || x >= width) continue
|
|
102
|
+
const d = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2)
|
|
103
|
+
if (d <= r) put(x, y, 89, 199, 52) // green #34c759 (BGRA)
|
|
104
|
+
else if (d <= r + ring) put(x, y, 24, 26, 31) // dark ring #1f1a18
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return nativeImage.createFromBitmap(bmp, { width, height })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Show/clear the green "ready" dot on the macOS dock icon.
|
|
111
|
+
function setDockReadyDot(on: boolean): void {
|
|
112
|
+
if (process.platform !== 'darwin' || !app.dock || !baseDockIcon) return
|
|
113
|
+
app.dock.setIcon(on ? dockIconWithReadyDot(baseDockIcon) : baseDockIcon)
|
|
114
|
+
}
|
|
115
|
+
|
|
76
116
|
// Last project abbreviation, so the steady badge can be restored after the
|
|
77
117
|
// "awaiting input" indicator clears.
|
|
78
118
|
let projectAbbrev = ''
|
|
@@ -90,8 +130,9 @@ function updateProjectBadge(cwdPath: string | null): void {
|
|
|
90
130
|
if (!cwdPath) return
|
|
91
131
|
projectAbbrev = getProjectAbbrev(cwdPath)
|
|
92
132
|
|
|
93
|
-
//
|
|
94
|
-
|
|
133
|
+
// The project badge and the "ready" dot are now independent surfaces (badge
|
|
134
|
+
// vs dock icon), so the badge can always update.
|
|
135
|
+
applySteadyBadge()
|
|
95
136
|
|
|
96
137
|
// All platforms: update window title
|
|
97
138
|
if (mainWindow) {
|
|
@@ -100,18 +141,17 @@ function updateProjectBadge(cwdPath: string | null): void {
|
|
|
100
141
|
}
|
|
101
142
|
}
|
|
102
143
|
|
|
103
|
-
// ─── "
|
|
144
|
+
// ─── "Ready for you" attention indicator (cross-platform) ──────────
|
|
104
145
|
//
|
|
105
146
|
// The detection is OS-independent; only the visual differs:
|
|
106
|
-
// macOS —
|
|
107
|
-
// Windows —
|
|
147
|
+
// macOS — steady green dot composited on the dock icon + one bounce
|
|
148
|
+
// Windows — green overlay icon on the taskbar button + flash the button
|
|
108
149
|
// Linux — badge count where supported + flash (window urgency hint)
|
|
109
150
|
// While the window is focused the dock/taskbar nag is suppressed (the in-app
|
|
110
151
|
// dot is enough); the renderer always gets `agent:state` regardless of OS.
|
|
111
152
|
|
|
112
153
|
const attention = new AttentionController()
|
|
113
|
-
let
|
|
114
|
-
let blinkOn = false
|
|
154
|
+
let attentionActive = false
|
|
115
155
|
let bounceId = -1
|
|
116
156
|
|
|
117
157
|
function setAwaitingIndicator(on: boolean): void {
|
|
@@ -124,40 +164,31 @@ function applyAttention(active: boolean): void {
|
|
|
124
164
|
}
|
|
125
165
|
|
|
126
166
|
function startAttention(): void {
|
|
127
|
-
if (
|
|
128
|
-
|
|
167
|
+
if (attentionActive) return
|
|
168
|
+
attentionActive = true
|
|
169
|
+
log('[agent-state] attention START (green ready dot + bounce)')
|
|
129
170
|
|
|
130
171
|
if (process.platform === 'darwin' && app.dock) {
|
|
172
|
+
setDockReadyDot(true)
|
|
131
173
|
bounceId = app.dock.bounce('informational')
|
|
132
174
|
}
|
|
133
175
|
if (process.platform === 'win32' && mainWindow) {
|
|
134
|
-
mainWindow.setOverlayIcon(createDotOverlay(true), '
|
|
176
|
+
mainWindow.setOverlayIcon(createDotOverlay(true), 'Ready for you')
|
|
135
177
|
mainWindow.flashFrame(true)
|
|
136
178
|
}
|
|
137
179
|
if (process.platform === 'linux') {
|
|
138
180
|
if (typeof app.badgeCount === 'number') app.badgeCount = 1
|
|
139
181
|
mainWindow?.flashFrame(true)
|
|
140
182
|
}
|
|
141
|
-
|
|
142
|
-
// Blink the macOS dock badge — the whole red pill (project initials and all)
|
|
143
|
-
// toggles on/off, so the project letters are preserved on the "on" frames
|
|
144
|
-
// rather than being replaced by a dot.
|
|
145
|
-
blinkOn = true
|
|
146
|
-
blinkTimer = setInterval(() => {
|
|
147
|
-
blinkOn = !blinkOn
|
|
148
|
-
if (process.platform === 'darwin' && app.dock) {
|
|
149
|
-
app.dock.setBadge(blinkOn ? (projectAbbrev || '●') : '')
|
|
150
|
-
}
|
|
151
|
-
}, 600)
|
|
152
183
|
}
|
|
153
184
|
|
|
154
185
|
function stopAttention(): void {
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
186
|
+
if (!attentionActive) return
|
|
187
|
+
attentionActive = false
|
|
188
|
+
log('[agent-state] attention STOP')
|
|
189
|
+
|
|
160
190
|
if (process.platform === 'darwin' && app.dock) {
|
|
191
|
+
setDockReadyDot(false)
|
|
161
192
|
if (bounceId >= 0) { app.dock.cancelBounce(bounceId); bounceId = -1 }
|
|
162
193
|
}
|
|
163
194
|
if (process.platform === 'win32' && mainWindow) {
|
|
@@ -173,7 +204,7 @@ function stopAttention(): void {
|
|
|
173
204
|
// ─── Orchestrator (shared logic) ──────────────────
|
|
174
205
|
|
|
175
206
|
const orchestrator = new Orchestrator(
|
|
176
|
-
|
|
207
|
+
SETTINGS_DIR,
|
|
177
208
|
{
|
|
178
209
|
onPtyData: (tabId, data) => mainWindow?.webContents.send('pty:data', tabId, data),
|
|
179
210
|
onPtyExit: (tabId, code) => mainWindow?.webContents.send('pty:exit', tabId, code),
|
|
@@ -270,6 +301,56 @@ ipcMain.handle('pty:setActiveTab', (_event, tabId: string) => {
|
|
|
270
301
|
ipcMain.handle('agents:list', () => getAvailableAgents())
|
|
271
302
|
ipcMain.handle('agents:default', () => getDefaultAgent())
|
|
272
303
|
|
|
304
|
+
// ─── Remote (SSH) ─────────────────────────────────
|
|
305
|
+
|
|
306
|
+
// Hosts to offer: recently-used targets first, then ~/.ssh/config aliases.
|
|
307
|
+
ipcMain.handle('ssh:listHosts', () => ({
|
|
308
|
+
configHosts: listSshHosts(),
|
|
309
|
+
recents: getRecentHosts(SETTINGS_DIR),
|
|
310
|
+
}))
|
|
311
|
+
|
|
312
|
+
// Non-interactive reachability + tmux probe (key-auth hosts only).
|
|
313
|
+
ipcMain.handle('ssh:probe', (_event, target: string) => probeRemote(target))
|
|
314
|
+
|
|
315
|
+
// Build the ssh-backed AgentConfig for a chosen connect action.
|
|
316
|
+
ipcMain.handle('ssh:buildAgent', (_event, target: string, action: RemoteAction, agentCmd?: string) =>
|
|
317
|
+
buildRemoteAgent({ target, action, agentCmd }),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
// Remember a target as most-recently-used (no secrets stored — just the host).
|
|
321
|
+
ipcMain.handle('ssh:addRecent', (_event, target: string) => {
|
|
322
|
+
addRecentHost(SETTINGS_DIR, target)
|
|
323
|
+
return { ok: true }
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// Stash an in-app password for the next remote spawn to `target`. Held only in
|
|
327
|
+
// memory (auto-expires), injected once into ssh's prompt, then dropped.
|
|
328
|
+
ipcMain.handle('ssh:setPassword', (_event, target: string, password: string) => {
|
|
329
|
+
orchestrator.setPendingSshPassword(target, password)
|
|
330
|
+
return { ok: true }
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
// ─── ctlsurf project selection (for remote sessions) ──
|
|
334
|
+
|
|
335
|
+
// List existing ctlsurf projects (folders) to pick from.
|
|
336
|
+
ipcMain.handle('ctlsurf:listProjects', async () => {
|
|
337
|
+
if (!orchestrator.ctlsurfApi.getApiKey()) return { ok: false, projects: [], error: 'No ctlsurf API key configured' }
|
|
338
|
+
try {
|
|
339
|
+
const folders = await orchestrator.ctlsurfApi.listFolders()
|
|
340
|
+
const projects = folders
|
|
341
|
+
.filter((f) => f && f.id && f.root_path)
|
|
342
|
+
.map((f) => ({ id: f.id, name: f.name || '(unnamed)', root_path: f.root_path as string }))
|
|
343
|
+
return { ok: true, projects }
|
|
344
|
+
} catch (err: any) {
|
|
345
|
+
return { ok: false, projects: [], error: err?.message || 'Failed to list projects' }
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
// Bind the active (remote) tab to a chosen project by its root_path.
|
|
350
|
+
ipcMain.handle('ctlsurf:bindProject', (_event, rootPath: string) =>
|
|
351
|
+
orchestrator.bindActiveTabToProject(rootPath),
|
|
352
|
+
)
|
|
353
|
+
|
|
273
354
|
ipcMain.handle('app:homePath', () => app.getPath('home'))
|
|
274
355
|
ipcMain.handle('app:cwd', () => process.env.CTLSURF_WORKER_CWD || process.cwd())
|
|
275
356
|
ipcMain.handle('app:projectName', () => orchestrator.projectName)
|
|
@@ -414,7 +495,8 @@ ipcMain.handle('worker:getWebviewInfo', async () => {
|
|
|
414
495
|
try {
|
|
415
496
|
const { code } = await orchestrator.ctlsurfApi.getAuthCode()
|
|
416
497
|
let pageUrl = `${frontendUrl}?embed=1&_code=${encodeURIComponent(code)}`
|
|
417
|
-
|
|
498
|
+
// Prefer the active tab's bound project (remote sessions), else the local cwd.
|
|
499
|
+
const currentCwd = orchestrator.getActiveTabProjectPath() || orchestrator.cwd
|
|
418
500
|
|
|
419
501
|
if (currentCwd) {
|
|
420
502
|
try {
|
|
@@ -520,7 +602,8 @@ ipcMain.handle('tracking:set', async (_event, enabled: boolean) => {
|
|
|
520
602
|
// ─── Tickets IPC ──────────────────────────────────
|
|
521
603
|
|
|
522
604
|
ipcMain.handle('tickets:project', () => {
|
|
523
|
-
|
|
605
|
+
// Use the bound project path for remote sessions, else the local cwd.
|
|
606
|
+
const cwd = orchestrator.getActiveTabProjectPath()
|
|
524
607
|
return { cwd, name: cwd ? cwd.split('/').filter(Boolean).pop() || cwd : null }
|
|
525
608
|
})
|
|
526
609
|
ipcMain.handle('tickets:add', async (_event, input: {
|
|
@@ -582,7 +665,8 @@ app.whenReady().then(() => {
|
|
|
582
665
|
if (process.platform === 'darwin' && app.dock) {
|
|
583
666
|
const iconPath = path.join(__dirname, '../../resources/icon.png')
|
|
584
667
|
try {
|
|
585
|
-
|
|
668
|
+
baseDockIcon = nativeImage.createFromPath(iconPath)
|
|
669
|
+
app.dock.setIcon(baseDockIcon)
|
|
586
670
|
} catch { /* ignore */ }
|
|
587
671
|
}
|
|
588
672
|
|
package/src/main/orchestrator.ts
CHANGED
|
@@ -3,7 +3,8 @@ import fs from 'fs'
|
|
|
3
3
|
import os from 'os'
|
|
4
4
|
|
|
5
5
|
import { PtyManager } from './pty'
|
|
6
|
-
import { AgentConfig, isCodingAgent } from './agents'
|
|
6
|
+
import { AgentConfig, isCodingAgent, isRemoteAgent } from './agents'
|
|
7
|
+
import { stripAnsiSeq, isPasswordPrompt } from './ssh'
|
|
7
8
|
import { CtlsurfApi } from './ctlsurfApi'
|
|
8
9
|
import { ConversationBridge } from './bridge'
|
|
9
10
|
import { TranscriptTailer, supportsTranscriptLogging, readLatestAssistantReply } from './transcripts'
|
|
@@ -62,6 +63,10 @@ interface TabState {
|
|
|
62
63
|
ptyManager: PtyManager
|
|
63
64
|
agent: AgentConfig
|
|
64
65
|
cwd: string
|
|
66
|
+
// For remote sessions bound to a ctlsurf project: the project's root_path,
|
|
67
|
+
// used in place of the (meaningless) local cwd for project-scoped work like
|
|
68
|
+
// tickets. Set by bindActiveTabToProject.
|
|
69
|
+
projectRootPath?: string
|
|
65
70
|
termStreamBuffer: string
|
|
66
71
|
termStreamTimer: ReturnType<typeof setTimeout> | null
|
|
67
72
|
}
|
|
@@ -108,6 +113,10 @@ export class Orchestrator {
|
|
|
108
113
|
private currentProjectName: string | null = null
|
|
109
114
|
private transcriptTailer: TranscriptTailer | null = null
|
|
110
115
|
private readonly stateMonitor: AgentStateMonitor
|
|
116
|
+
// One-shot SSH passwords entered in-app, keyed by target. Held only in memory
|
|
117
|
+
// until the next remote spawn for that target consumes them — never persisted.
|
|
118
|
+
private readonly pendingSshPasswords = new Map<string, string>()
|
|
119
|
+
private readonly pendingSshPasswordTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
111
120
|
|
|
112
121
|
constructor(settingsDir: string, events: OrchestratorEvents) {
|
|
113
122
|
this.settingsDir = settingsDir
|
|
@@ -455,6 +464,50 @@ export class Orchestrator {
|
|
|
455
464
|
|
|
456
465
|
// ─── PTY & Agent (multi-tab) ─────────────────────
|
|
457
466
|
|
|
467
|
+
// Stash an in-app SSH password for the next remote spawn to `target`. Kept
|
|
468
|
+
// only in memory and auto-expired so a typed-but-unused password doesn't
|
|
469
|
+
// linger. Never written to disk or logged.
|
|
470
|
+
setPendingSshPassword(target: string, password: string): void {
|
|
471
|
+
if (!target || !password) return
|
|
472
|
+
const prev = this.pendingSshPasswordTimers.get(target)
|
|
473
|
+
if (prev) clearTimeout(prev)
|
|
474
|
+
this.pendingSshPasswords.set(target, password)
|
|
475
|
+
this.pendingSshPasswordTimers.set(target, setTimeout(() => {
|
|
476
|
+
this.pendingSshPasswords.delete(target)
|
|
477
|
+
this.pendingSshPasswordTimers.delete(target)
|
|
478
|
+
}, 120_000))
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private consumePendingSshPassword(target: string): string | undefined {
|
|
482
|
+
const pw = this.pendingSshPasswords.get(target)
|
|
483
|
+
if (pw === undefined) return undefined
|
|
484
|
+
this.pendingSshPasswords.delete(target)
|
|
485
|
+
const timer = this.pendingSshPasswordTimers.get(target)
|
|
486
|
+
if (timer) { clearTimeout(timer); this.pendingSshPasswordTimers.delete(target) }
|
|
487
|
+
return pw
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Watch a freshly-spawned ssh PTY for its password/passphrase prompt and write
|
|
491
|
+
// the password once. We write straight to the PTY (not through writePty) so it
|
|
492
|
+
// never reaches the bridge/chat-logging path; ssh keeps echo off, so it never
|
|
493
|
+
// renders to the terminal or scrollback either.
|
|
494
|
+
private armPasswordInjector(ptyManager: PtyManager, password: string): void {
|
|
495
|
+
let pw: string | null = password
|
|
496
|
+
let tail = ''
|
|
497
|
+
const giveUp = setTimeout(() => { pw = null; tail = '' }, 30_000)
|
|
498
|
+
ptyManager.onData((data: string) => {
|
|
499
|
+
if (pw === null) return
|
|
500
|
+
tail = (tail + stripAnsiSeq(data)).slice(-200)
|
|
501
|
+
if (isPasswordPrompt(tail)) {
|
|
502
|
+
log('[ssh] password prompt detected — injecting in-app password (one-shot)')
|
|
503
|
+
ptyManager.write(pw + '\r')
|
|
504
|
+
pw = null
|
|
505
|
+
tail = ''
|
|
506
|
+
clearTimeout(giveUp)
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
|
|
458
511
|
async spawnAgent(tabId: string, agent: AgentConfig, cwd: string, opts?: { trackTime?: boolean }): Promise<void> {
|
|
459
512
|
// Kill existing PTY on this tab if any
|
|
460
513
|
const existing = this.tabs.get(tabId)
|
|
@@ -479,6 +532,18 @@ export class Orchestrator {
|
|
|
479
532
|
// exactly when the dock indicator is most useful.
|
|
480
533
|
this.stateMonitor.trackTab(tabId, agent.id, cwd)
|
|
481
534
|
|
|
535
|
+
// Remote (SSH) tab with an in-app password waiting: feed it to ssh's prompt
|
|
536
|
+
// once, directly over the PTY. The password is consumed here (removed from
|
|
537
|
+
// the pending map) and dropped after injection — never logged or persisted.
|
|
538
|
+
if (isRemoteAgent(agent)) {
|
|
539
|
+
const target = agent.args[1] // args are ['-t', target, ...]
|
|
540
|
+
const pw = target ? this.consumePendingSshPassword(target) : undefined
|
|
541
|
+
if (pw) {
|
|
542
|
+
log(`[ssh] remote spawn for ${target}: in-app password armed (will inject at prompt)`)
|
|
543
|
+
this.armPasswordInjector(ptyManager, pw)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
482
547
|
ptyManager.onData((data: string) => {
|
|
483
548
|
this.events.onPtyData(tabId, data)
|
|
484
549
|
this.timeTracker.recordActivity(tabId)
|
|
@@ -607,8 +672,19 @@ export class Orchestrator {
|
|
|
607
672
|
return this.tabs.get(this.activeTabId)?.cwd ?? null
|
|
608
673
|
}
|
|
609
674
|
|
|
675
|
+
/**
|
|
676
|
+
* Path used to resolve the active tab's ctlsurf project. For a remote session
|
|
677
|
+
* bound to a project this is the project's root_path; otherwise the local cwd.
|
|
678
|
+
*/
|
|
679
|
+
getActiveTabProjectPath(): string | null {
|
|
680
|
+
if (!this.activeTabId) return null
|
|
681
|
+
const tab = this.tabs.get(this.activeTabId)
|
|
682
|
+
if (!tab) return null
|
|
683
|
+
return tab.projectRootPath ?? tab.cwd
|
|
684
|
+
}
|
|
685
|
+
|
|
610
686
|
async addTicketForActiveTab(input: TicketInput): Promise<{ ok: boolean; error?: string }> {
|
|
611
|
-
const cwd = this.
|
|
687
|
+
const cwd = this.getActiveTabProjectPath()
|
|
612
688
|
if (!cwd) return { ok: false, error: 'No active terminal tab' }
|
|
613
689
|
if (!this.ctlsurfApi.getApiKey()) {
|
|
614
690
|
return { ok: false, error: 'ctlsurf API key not configured' }
|
|
@@ -617,7 +693,7 @@ export class Orchestrator {
|
|
|
617
693
|
}
|
|
618
694
|
|
|
619
695
|
async updateTicketForActiveTab(rowId: string, input: TicketInput): Promise<{ ok: boolean; error?: string }> {
|
|
620
|
-
const cwd = this.
|
|
696
|
+
const cwd = this.getActiveTabProjectPath()
|
|
621
697
|
if (!cwd) return { ok: false, error: 'No active terminal tab' }
|
|
622
698
|
if (!this.ctlsurfApi.getApiKey()) {
|
|
623
699
|
return { ok: false, error: 'ctlsurf API key not configured' }
|
|
@@ -626,7 +702,7 @@ export class Orchestrator {
|
|
|
626
702
|
}
|
|
627
703
|
|
|
628
704
|
async listTicketsForActiveTab(): Promise<{ ok: boolean; tickets: import('./ticketStore').Ticket[]; error?: string }> {
|
|
629
|
-
const cwd = this.
|
|
705
|
+
const cwd = this.getActiveTabProjectPath()
|
|
630
706
|
if (!cwd) return { ok: false, tickets: [], error: 'No active terminal tab' }
|
|
631
707
|
if (!this.ctlsurfApi.getApiKey()) {
|
|
632
708
|
return { ok: false, tickets: [], error: 'ctlsurf API key not configured' }
|
|
@@ -636,6 +712,31 @@ export class Orchestrator {
|
|
|
636
712
|
|
|
637
713
|
// ─── Worker WebSocket ───────────────────────────
|
|
638
714
|
|
|
715
|
+
// Bind the active tab (typically a remote SSH session, whose local cwd is
|
|
716
|
+
// meaningless) to a chosen ctlsurf project. We register the worker WS using
|
|
717
|
+
// the project's root_path so the backend resolves that folder — the same
|
|
718
|
+
// mechanism create-project relies on. This lights up chat logging, tickets,
|
|
719
|
+
// remote-control, and the project header/panel for the remote session.
|
|
720
|
+
bindActiveTabToProject(rootPath: string): { ok: boolean; error?: string } {
|
|
721
|
+
if (!rootPath) return { ok: false, error: 'No project path' }
|
|
722
|
+
if (!this.activeTabId) return { ok: false, error: 'No active tab' }
|
|
723
|
+
const tab = this.tabs.get(this.activeTabId)
|
|
724
|
+
if (!tab) return { ok: false, error: 'No active tab' }
|
|
725
|
+
if (!this.ctlsurfApi.getApiKey()) return { ok: false, error: 'No ctlsurf API key configured' }
|
|
726
|
+
|
|
727
|
+
log(`[ctlsurf] Binding tab agent="${tab.agent.name}" to project root_path=${rootPath}`)
|
|
728
|
+
// Remember the binding so project-scoped work (tickets) targets the chosen
|
|
729
|
+
// project instead of the remote tab's local cwd.
|
|
730
|
+
tab.projectRootPath = rootPath
|
|
731
|
+
this.stopNoProjectPolling()
|
|
732
|
+
this.workerWs.disconnect()
|
|
733
|
+
this.connectWorkerWs(tab.agent, rootPath)
|
|
734
|
+
// Tell the renderer the active project changed so the tickets + ctlsurf
|
|
735
|
+
// panels re-query against the bound project (they refresh on this event).
|
|
736
|
+
this.events.onCwdChanged()
|
|
737
|
+
return { ok: true }
|
|
738
|
+
}
|
|
739
|
+
|
|
639
740
|
connectWorkerWs(agent: AgentConfig, cwd: string): void {
|
|
640
741
|
const profile = this.getActiveProfile()
|
|
641
742
|
const apiKey = profile.apiKey || process.env.CTLSURF_API_KEY
|