@phenx-inc/ctlsurf 0.3.3 → 0.3.5
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/electron-vite.config.ts +2 -0
- package/out/headless/index.mjs +5682 -13
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +5962 -198
- package/out/preload/index.js +7 -0
- package/out/renderer/assets/{cssMode-BW-SuYuP.js → cssMode-DQW-brNd.js} +3 -3
- package/out/renderer/assets/{freemarker2-2YWYzawi.js → freemarker2-DxgOckH2.js} +1 -1
- package/out/renderer/assets/{handlebars-EwtUQRsf.js → handlebars-BX1Wpk_3.js} +1 -1
- package/out/renderer/assets/{html-BNZkIDb9.js → html-t-KXioI0.js} +1 -1
- package/out/renderer/assets/{htmlMode-C2dZKrOy.js → htmlMode-Dya7iUjr.js} +3 -3
- package/out/renderer/assets/{index-CrTu3Z4M.css → index-D6JBcQ20.css} +13 -0
- package/out/renderer/assets/{index-Bm_rbVP-.js → index-DNqZidnO.js} +49 -25
- package/out/renderer/assets/{javascript-busdVZMv.js → javascript-DZzW2adn.js} +2 -2
- package/out/renderer/assets/{jsonMode-BaVI6jAw.js → jsonMode-D_Wv7XH8.js} +3 -3
- package/out/renderer/assets/{liquid-DG08un1Q.js → liquid-BJAHAm2T.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-peGVtLxi.js → lspLanguageFeatures-BgMd-KJk.js} +1 -1
- package/out/renderer/assets/{mdx-DogBhUxZ.js → mdx-B6Zod3ry.js} +1 -1
- package/out/renderer/assets/{python-Bf-INYXh.js → python-Cgt13-KH.js} +1 -1
- package/out/renderer/assets/{razor-DLrZ2hsF.js → razor-BcwFJGYS.js} +1 -1
- package/out/renderer/assets/{tsMode-B4oEmliC.js → tsMode-BTjzM6fl.js} +1 -1
- package/out/renderer/assets/{typescript-CjkgfhVK.js → typescript-DZYDQEUb.js} +1 -1
- package/out/renderer/assets/{xml-0FAXmuVg.js → xml-CloiUoIW.js} +1 -1
- package/out/renderer/assets/{yaml-DWxnPuy8.js → yaml-CdKdpE-z.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +3 -1
- package/src/main/bridge.ts +269 -20
- package/src/main/index.ts +76 -0
- package/src/main/orchestrator.ts +1 -1
- package/src/main/timeTracker.ts +47 -1
- package/src/main/workerWs.ts +7 -1
- package/src/main/xterm-headless.d.ts +3 -0
- package/src/preload/index.ts +9 -0
- package/src/renderer/App.tsx +21 -1
- package/src/renderer/components/StatusBar.tsx +26 -1
- package/src/renderer/styles.css +13 -0
package/src/main/bridge.ts
CHANGED
|
@@ -1,28 +1,34 @@
|
|
|
1
1
|
import { WorkerWsClient } from './workerWs'
|
|
2
|
+
import { Terminal as HeadlessTerminal } from '@xterm/headless/lib-headless/xterm-headless.js'
|
|
3
|
+
import { SerializeAddon } from '@xterm/addon-serialize'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Conversation Bridge
|
|
5
7
|
*
|
|
6
|
-
* Captures PTY output,
|
|
7
|
-
* and sends cleaned text to the backend via WebSocket for chat logging.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Captures PTY output, renders it through a headless terminal,
|
|
9
|
+
* and sends cleaned text snapshots to the backend via WebSocket for chat logging.
|
|
10
|
+
* Flushes only after output settles so TUI redraws don't leak every
|
|
11
|
+
* intermediate repaint frame into chat logs.
|
|
10
12
|
*/
|
|
11
13
|
export class ConversationBridge {
|
|
12
14
|
private wsClient: WorkerWsClient | null = null
|
|
13
15
|
private sessionActive: boolean = false
|
|
14
16
|
private inputBuffer: string = ''
|
|
15
17
|
private outputBuffer: string = ''
|
|
16
|
-
|
|
17
|
-
private
|
|
18
|
+
private outputFlushTimer: ReturnType<typeof setTimeout> | null = null
|
|
19
|
+
private pendingCandidate = ''
|
|
20
|
+
private pendingCandidateChangedAt = 0
|
|
21
|
+
private readonly terminalCapture = new TerminalCapture()
|
|
18
22
|
|
|
19
23
|
setWsClient(ws: WorkerWsClient): void {
|
|
20
24
|
this.wsClient = ws
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
startSession(): void {
|
|
28
|
+
this.clearOutputTimers()
|
|
24
29
|
this.outputBuffer = ''
|
|
25
30
|
this.inputBuffer = ''
|
|
31
|
+
this.terminalCapture.reset()
|
|
26
32
|
this.sessionActive = true
|
|
27
33
|
console.log('[bridge] Session started')
|
|
28
34
|
}
|
|
@@ -31,10 +37,8 @@ export class ConversationBridge {
|
|
|
31
37
|
if (!this.sessionActive) return
|
|
32
38
|
|
|
33
39
|
this.outputBuffer += data
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
this.flush()
|
|
37
|
-
}
|
|
40
|
+
this.terminalCapture.write(data)
|
|
41
|
+
this.scheduleOutputFlush()
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
feedInput(data: string): void {
|
|
@@ -50,17 +54,17 @@ export class ConversationBridge {
|
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
56
|
|
|
53
|
-
resize(
|
|
54
|
-
|
|
57
|
+
resize(cols: number, rows: number): void {
|
|
58
|
+
this.terminalCapture.resize(cols, rows)
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
private flush(): void {
|
|
62
|
+
this.clearOutputTimers()
|
|
58
63
|
if (this.outputBuffer.length === 0) return
|
|
59
64
|
|
|
60
|
-
const raw = this.outputBuffer
|
|
61
65
|
this.outputBuffer = ''
|
|
62
66
|
|
|
63
|
-
const cleaned =
|
|
67
|
+
const cleaned = this.terminalCapture.readCommittedDelta()
|
|
64
68
|
if (cleaned.length === 0) return
|
|
65
69
|
|
|
66
70
|
this.sendEntry('terminal_output', cleaned)
|
|
@@ -75,6 +79,50 @@ export class ConversationBridge {
|
|
|
75
79
|
})
|
|
76
80
|
}
|
|
77
81
|
|
|
82
|
+
private scheduleOutputFlush(): void {
|
|
83
|
+
if (!this.outputFlushTimer) {
|
|
84
|
+
this.outputFlushTimer = setTimeout(() => {
|
|
85
|
+
this.checkOutputStability()
|
|
86
|
+
}, OUTPUT_STABILITY_CHECK_MS)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private checkOutputStability(): void {
|
|
91
|
+
this.outputFlushTimer = null
|
|
92
|
+
if (this.outputBuffer.length === 0) return
|
|
93
|
+
|
|
94
|
+
const candidate = this.terminalCapture.currentCandidate()
|
|
95
|
+
const now = Date.now()
|
|
96
|
+
|
|
97
|
+
if (candidate.length === 0) {
|
|
98
|
+
this.outputBuffer = ''
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (candidate !== this.pendingCandidate) {
|
|
103
|
+
this.pendingCandidate = candidate
|
|
104
|
+
this.pendingCandidateChangedAt = now
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (now - this.pendingCandidateChangedAt >= OUTPUT_STABLE_MS) {
|
|
108
|
+
this.flush()
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.outputFlushTimer = setTimeout(() => {
|
|
113
|
+
this.checkOutputStability()
|
|
114
|
+
}, OUTPUT_STABILITY_CHECK_MS)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private clearOutputTimers(): void {
|
|
118
|
+
if (this.outputFlushTimer) {
|
|
119
|
+
clearTimeout(this.outputFlushTimer)
|
|
120
|
+
this.outputFlushTimer = null
|
|
121
|
+
}
|
|
122
|
+
this.pendingCandidate = ''
|
|
123
|
+
this.pendingCandidateChangedAt = 0
|
|
124
|
+
}
|
|
125
|
+
|
|
78
126
|
endSession(): void {
|
|
79
127
|
if (!this.sessionActive) return
|
|
80
128
|
|
|
@@ -87,6 +135,114 @@ export class ConversationBridge {
|
|
|
87
135
|
}
|
|
88
136
|
}
|
|
89
137
|
|
|
138
|
+
const DEFAULT_COLS = 80
|
|
139
|
+
const DEFAULT_ROWS = 24
|
|
140
|
+
const OUTPUT_STABILITY_CHECK_MS = 250
|
|
141
|
+
const OUTPUT_STABLE_MS = 1800
|
|
142
|
+
const MAX_SNAPSHOT_LINES = 200
|
|
143
|
+
const MAX_LOG_CHARS = 20_000
|
|
144
|
+
|
|
145
|
+
class TerminalCapture {
|
|
146
|
+
private terminal: HeadlessTerminal
|
|
147
|
+
private serializeAddon: SerializeAddon
|
|
148
|
+
private lastCommittedCandidate = ''
|
|
149
|
+
|
|
150
|
+
constructor() {
|
|
151
|
+
this.terminal = this.createTerminal(DEFAULT_COLS, DEFAULT_ROWS)
|
|
152
|
+
this.serializeAddon = this.createSerializeAddon(this.terminal)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
reset(): void {
|
|
156
|
+
this.terminal.dispose()
|
|
157
|
+
this.terminal = this.createTerminal(DEFAULT_COLS, DEFAULT_ROWS)
|
|
158
|
+
this.serializeAddon = this.createSerializeAddon(this.terminal)
|
|
159
|
+
this.lastCommittedCandidate = ''
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
resize(cols: number, rows: number): void {
|
|
163
|
+
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return
|
|
164
|
+
|
|
165
|
+
const nextCols = Math.max(10, Math.floor(cols))
|
|
166
|
+
const nextRows = Math.max(5, Math.floor(rows))
|
|
167
|
+
this.terminal.resize(nextCols, nextRows)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
write(data: string): void {
|
|
171
|
+
const writable = this.terminal as HeadlessTerminal & {
|
|
172
|
+
writeSync?: (data: string) => void
|
|
173
|
+
_core?: { writeSync?: (data: string) => void }
|
|
174
|
+
}
|
|
175
|
+
if (typeof writable.writeSync === 'function') {
|
|
176
|
+
writable.writeSync(data)
|
|
177
|
+
} else if (typeof writable._core?.writeSync === 'function') {
|
|
178
|
+
// The public headless API only exposes async write(). The bridge flushes
|
|
179
|
+
// synchronously on byte thresholds/session end, so use xterm's sync core
|
|
180
|
+
// writer for deterministic chat-log snapshots.
|
|
181
|
+
writable._core.writeSync(data)
|
|
182
|
+
} else {
|
|
183
|
+
this.terminal.write(data)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
readCommittedDelta(): string {
|
|
188
|
+
const candidate = this.currentCandidate()
|
|
189
|
+
const delta = diffSnapshots(this.lastCommittedCandidate, candidate)
|
|
190
|
+
this.lastCommittedCandidate = candidate
|
|
191
|
+
return trimToLogLimit(delta)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
currentCandidate(): string {
|
|
195
|
+
return extractTranscriptCandidate(normalizeTerminalText(this.readSnapshot()))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private createTerminal(cols: number, rows: number): HeadlessTerminal {
|
|
199
|
+
return new HeadlessTerminal({
|
|
200
|
+
cols,
|
|
201
|
+
rows,
|
|
202
|
+
scrollback: MAX_SNAPSHOT_LINES,
|
|
203
|
+
allowProposedApi: true,
|
|
204
|
+
logLevel: 'off',
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private createSerializeAddon(terminal: HeadlessTerminal): SerializeAddon {
|
|
209
|
+
const addon = new SerializeAddon()
|
|
210
|
+
terminal.loadAddon(addon as any)
|
|
211
|
+
return addon
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private readSnapshot(): string {
|
|
215
|
+
try {
|
|
216
|
+
return this.readPlainBufferSnapshot()
|
|
217
|
+
} catch {
|
|
218
|
+
return this.serializeAddon.serialize({
|
|
219
|
+
scrollback: MAX_SNAPSHOT_LINES,
|
|
220
|
+
excludeModes: true,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private readPlainBufferSnapshot(): string {
|
|
226
|
+
const buffer = this.terminal.buffer.active
|
|
227
|
+
const start = Math.max(0, buffer.length - MAX_SNAPSHOT_LINES)
|
|
228
|
+
const lines: string[] = []
|
|
229
|
+
|
|
230
|
+
for (let i = start; i < buffer.length; i++) {
|
|
231
|
+
const line = buffer.getLine(i)
|
|
232
|
+
if (!line) continue
|
|
233
|
+
|
|
234
|
+
const text = line.translateToString(true)
|
|
235
|
+
if (line.isWrapped && lines.length > 0) {
|
|
236
|
+
lines[lines.length - 1] += text
|
|
237
|
+
} else {
|
|
238
|
+
lines.push(text)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return lines.join('\n')
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
90
246
|
/**
|
|
91
247
|
* Strip ANSI escape codes from a string.
|
|
92
248
|
*/
|
|
@@ -131,18 +287,111 @@ function cleanInput(str: string): string {
|
|
|
131
287
|
}
|
|
132
288
|
|
|
133
289
|
/**
|
|
134
|
-
* Clean terminal
|
|
290
|
+
* Clean rendered terminal text: strip any remaining ANSI, remove control chars, collapse noise.
|
|
135
291
|
*/
|
|
136
|
-
function
|
|
292
|
+
function normalizeTerminalText(str: string): string {
|
|
137
293
|
let cleaned = stripAnsi(str)
|
|
138
|
-
|
|
139
|
-
cleaned = cleaned.replace(/\r/g, '')
|
|
294
|
+
cleaned = cleaned.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
140
295
|
// Remove control characters except newline/tab
|
|
141
296
|
// eslint-disable-next-line no-control-regex
|
|
142
297
|
cleaned = cleaned.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
|
298
|
+
cleaned = cleaned.replace(/[ \t]+\n/g, '\n')
|
|
143
299
|
// Collapse 3+ consecutive newlines into 2
|
|
144
300
|
cleaned = cleaned.replace(/\n{3,}/g, '\n\n')
|
|
145
|
-
// Remove lines that are only whitespace
|
|
146
|
-
cleaned = cleaned.split('\n').filter(line => line.trim().length > 0).join('\n')
|
|
147
301
|
return cleaned.trim()
|
|
148
302
|
}
|
|
303
|
+
|
|
304
|
+
function extractTranscriptCandidate(snapshot: string): string {
|
|
305
|
+
const lines = snapshot.split('\n')
|
|
306
|
+
const kept: string[] = []
|
|
307
|
+
let skipTipUrl = false
|
|
308
|
+
|
|
309
|
+
for (const rawLine of lines) {
|
|
310
|
+
const line = rawLine.trim()
|
|
311
|
+
|
|
312
|
+
if (skipTipUrl && isLikelyTipContinuation(line)) {
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
skipTipUrl = false
|
|
316
|
+
|
|
317
|
+
if (line.length === 0) {
|
|
318
|
+
if (kept.length > 0 && kept[kept.length - 1] !== '') {
|
|
319
|
+
kept.push('')
|
|
320
|
+
}
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (isTipLine(line)) {
|
|
325
|
+
skipTipUrl = true
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (isTerminalChromeLine(line) || isBusyScreenLine(line)) {
|
|
330
|
+
continue
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
kept.push(line)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return kept
|
|
337
|
+
.join('\n')
|
|
338
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
339
|
+
.trim()
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function diffSnapshots(previous: string, current: string): string {
|
|
343
|
+
if (current.length === 0 || current === previous) return ''
|
|
344
|
+
if (previous.length === 0) return current
|
|
345
|
+
if (current.startsWith(previous)) return current.slice(previous.length).trim()
|
|
346
|
+
|
|
347
|
+
const previousLines = previous.split('\n')
|
|
348
|
+
const currentLines = current.split('\n')
|
|
349
|
+
|
|
350
|
+
const maxOverlap = Math.min(previousLines.length, currentLines.length)
|
|
351
|
+
for (let overlap = maxOverlap; overlap > 0; overlap--) {
|
|
352
|
+
const previousTail = previousLines.slice(previousLines.length - overlap)
|
|
353
|
+
const currentHead = currentLines.slice(0, overlap)
|
|
354
|
+
if (linesEqual(previousTail, currentHead)) {
|
|
355
|
+
return currentLines.slice(overlap).join('\n').trim()
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return current
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function isTerminalChromeLine(line: string): boolean {
|
|
363
|
+
return (
|
|
364
|
+
/^[╭╰╮╯│─\s]+$/.test(line) ||
|
|
365
|
+
line.includes('OpenAI Codex') ||
|
|
366
|
+
line.includes('model:') ||
|
|
367
|
+
line.includes('directory:') ||
|
|
368
|
+
line.includes('/model to change') ||
|
|
369
|
+
/^gpt-[\w.-]+ .*·/.test(line) ||
|
|
370
|
+
/^›\s*/.test(line) ||
|
|
371
|
+
line === 'Write tests for @filename' ||
|
|
372
|
+
line === 'Implement {feature}' ||
|
|
373
|
+
/^[•◦∙·]$/.test(line)
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function isBusyScreenLine(line: string): boolean {
|
|
378
|
+
return /\bWorking \(\d+s\b/.test(line) || line.includes('esc to interrupt')
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function isTipLine(line: string): boolean {
|
|
382
|
+
return line.startsWith('Tip:')
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function isLikelyTipContinuation(line: string): boolean {
|
|
386
|
+
return line.startsWith('http://') || line.startsWith('https://') || line.includes('chatgpt.com/codex')
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function linesEqual(a: string[], b: string[]): boolean {
|
|
390
|
+
if (a.length !== b.length) return false
|
|
391
|
+
return a.every((line, index) => line === b[index])
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function trimToLogLimit(str: string): string {
|
|
395
|
+
if (str.length <= MAX_LOG_CHARS) return str
|
|
396
|
+
return `[truncated]\n${str.slice(str.length - MAX_LOG_CHARS)}`
|
|
397
|
+
}
|
package/src/main/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { app, BrowserWindow, ipcMain, dialog, nativeImage } from 'electron'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import fs from 'fs'
|
|
4
|
+
import https from 'https'
|
|
4
5
|
|
|
5
6
|
// Prevent EPIPE crashes when stdout pipe is closed
|
|
6
7
|
process.stdout?.on?.('error', () => {})
|
|
@@ -174,6 +175,77 @@ ipcMain.handle('app:browseCwd', async () => {
|
|
|
174
175
|
return result.filePaths[0]
|
|
175
176
|
})
|
|
176
177
|
|
|
178
|
+
// ─── Version + npm update check ───────────────────
|
|
179
|
+
|
|
180
|
+
const NPM_PACKAGE = '@phenx-inc/ctlsurf'
|
|
181
|
+
const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000 // 6 hours
|
|
182
|
+
|
|
183
|
+
interface UpdateInfo {
|
|
184
|
+
current: string
|
|
185
|
+
latest: string | null
|
|
186
|
+
hasUpdate: boolean
|
|
187
|
+
checkedAt: number | null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let updateInfo: UpdateInfo = {
|
|
191
|
+
current: app.getVersion(),
|
|
192
|
+
latest: null,
|
|
193
|
+
hasUpdate: false,
|
|
194
|
+
checkedAt: null,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function compareSemver(a: string, b: string): number {
|
|
198
|
+
const pa = a.split('.').map(n => parseInt(n, 10) || 0)
|
|
199
|
+
const pb = b.split('.').map(n => parseInt(n, 10) || 0)
|
|
200
|
+
for (let i = 0; i < 3; i++) {
|
|
201
|
+
const ai = pa[i] || 0
|
|
202
|
+
const bi = pb[i] || 0
|
|
203
|
+
if (ai !== bi) return ai - bi
|
|
204
|
+
}
|
|
205
|
+
return 0
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function fetchLatestNpmVersion(): Promise<string | null> {
|
|
209
|
+
return new Promise((resolve) => {
|
|
210
|
+
const url = `https://registry.npmjs.org/${encodeURIComponent(NPM_PACKAGE)}/latest`
|
|
211
|
+
const req = https.get(url, { headers: { 'Accept': 'application/json' } }, (res) => {
|
|
212
|
+
if (res.statusCode !== 200) {
|
|
213
|
+
res.resume()
|
|
214
|
+
resolve(null)
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
let body = ''
|
|
218
|
+
res.setEncoding('utf8')
|
|
219
|
+
res.on('data', (chunk) => { body += chunk })
|
|
220
|
+
res.on('end', () => {
|
|
221
|
+
try {
|
|
222
|
+
const json = JSON.parse(body)
|
|
223
|
+
resolve(typeof json?.version === 'string' ? json.version : null)
|
|
224
|
+
} catch {
|
|
225
|
+
resolve(null)
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
req.on('error', () => resolve(null))
|
|
230
|
+
req.setTimeout(8000, () => { req.destroy(); resolve(null) })
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function checkForUpdate(): Promise<void> {
|
|
235
|
+
const latest = await fetchLatestNpmVersion()
|
|
236
|
+
updateInfo = {
|
|
237
|
+
current: app.getVersion(),
|
|
238
|
+
latest,
|
|
239
|
+
hasUpdate: !!latest && compareSemver(latest, app.getVersion()) > 0,
|
|
240
|
+
checkedAt: Date.now(),
|
|
241
|
+
}
|
|
242
|
+
log(`[update] current=${updateInfo.current} latest=${latest ?? 'unknown'} hasUpdate=${updateInfo.hasUpdate}`)
|
|
243
|
+
mainWindow?.webContents.send('app:updateInfo', updateInfo)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
ipcMain.handle('app:getVersion', () => app.getVersion())
|
|
247
|
+
ipcMain.handle('app:getUpdateInfo', () => updateInfo)
|
|
248
|
+
|
|
177
249
|
// ─── Filesystem IPC ───────────────────────────────
|
|
178
250
|
|
|
179
251
|
ipcMain.handle('fs:readDir', async (_event, dirPath: string) => {
|
|
@@ -368,6 +440,10 @@ app.whenReady().then(() => {
|
|
|
368
440
|
|
|
369
441
|
orchestrator.loadSettings()
|
|
370
442
|
createWindow()
|
|
443
|
+
|
|
444
|
+
// Kick off an initial npm update check shortly after launch, then poll periodically.
|
|
445
|
+
setTimeout(() => { void checkForUpdate() }, 3000)
|
|
446
|
+
setInterval(() => { void checkForUpdate() }, UPDATE_CHECK_INTERVAL_MS)
|
|
371
447
|
})
|
|
372
448
|
|
|
373
449
|
app.on('window-all-closed', () => {
|
package/src/main/orchestrator.ts
CHANGED
|
@@ -104,7 +104,7 @@ export class Orchestrator {
|
|
|
104
104
|
const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null
|
|
105
105
|
if (activeTab) {
|
|
106
106
|
activeTab.ptyManager.write(message.content + '\r')
|
|
107
|
-
this.bridge.feedInput(message.content)
|
|
107
|
+
this.bridge.feedInput(message.content + '\r')
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
},
|
package/src/main/timeTracker.ts
CHANGED
|
@@ -11,6 +11,7 @@ const CHECKPOINT_INTERVAL_MS = 5 * 60 * 1000
|
|
|
11
11
|
const COLUMNS: Array<{ name: string; type: string }> = [
|
|
12
12
|
{ name: 'Started', type: 'text' },
|
|
13
13
|
{ name: 'Active Time', type: 'number' },
|
|
14
|
+
{ name: 'Last Updated', type: 'date' },
|
|
14
15
|
{ name: 'Agent', type: 'text' },
|
|
15
16
|
{ name: 'Worker', type: 'text' },
|
|
16
17
|
{ name: 'Session', type: 'text' },
|
|
@@ -32,6 +33,8 @@ interface SessionState {
|
|
|
32
33
|
blockId: string
|
|
33
34
|
rowId: string
|
|
34
35
|
cwd: string
|
|
36
|
+
agentName: string
|
|
37
|
+
idleTimeoutMin: number
|
|
35
38
|
startedAt: number
|
|
36
39
|
lastActivity: number
|
|
37
40
|
activeMs: number
|
|
@@ -41,6 +44,16 @@ interface SessionState {
|
|
|
41
44
|
ended: boolean
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
function isSameLocalDay(a: number, b: number): boolean {
|
|
48
|
+
const da = new Date(a)
|
|
49
|
+
const db = new Date(b)
|
|
50
|
+
return (
|
|
51
|
+
da.getFullYear() === db.getFullYear() &&
|
|
52
|
+
da.getMonth() === db.getMonth() &&
|
|
53
|
+
da.getDate() === db.getDate()
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
function log(...args: unknown[]): void {
|
|
45
58
|
try { console.log('[time-tracker]', ...args) } catch { /* EPIPE safe */ }
|
|
46
59
|
}
|
|
@@ -60,6 +73,7 @@ export class TimeTracker {
|
|
|
60
73
|
private api: CtlsurfApi
|
|
61
74
|
private sessions = new Map<string, SessionState>()
|
|
62
75
|
private blockCache = new Map<string, string>()
|
|
76
|
+
private rollingOver = new Set<string>()
|
|
63
77
|
|
|
64
78
|
constructor(api: CtlsurfApi) {
|
|
65
79
|
this.api = api
|
|
@@ -80,6 +94,7 @@ export class TimeTracker {
|
|
|
80
94
|
const row = await this.api.addRow(blockId, {
|
|
81
95
|
Started: formatStarted(startedAt),
|
|
82
96
|
'Active Time': 0,
|
|
97
|
+
'Last Updated': new Date(startedAt).toISOString(),
|
|
83
98
|
Agent: agentName,
|
|
84
99
|
Worker: os.hostname(),
|
|
85
100
|
Session: sessionUuid,
|
|
@@ -94,6 +109,8 @@ export class TimeTracker {
|
|
|
94
109
|
blockId,
|
|
95
110
|
rowId,
|
|
96
111
|
cwd,
|
|
112
|
+
agentName,
|
|
113
|
+
idleTimeoutMin,
|
|
97
114
|
startedAt,
|
|
98
115
|
lastActivity: startedAt,
|
|
99
116
|
activeMs: 0,
|
|
@@ -127,6 +144,10 @@ export class TimeTracker {
|
|
|
127
144
|
const s = this.sessions.get(tabId)
|
|
128
145
|
if (!s || s.ended) return
|
|
129
146
|
const now = Date.now()
|
|
147
|
+
if (!isSameLocalDay(s.startedAt, now)) {
|
|
148
|
+
void this.rollover(tabId)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
130
151
|
const delta = now - s.lastActivity
|
|
131
152
|
if (delta < s.idleTimeoutMs) {
|
|
132
153
|
s.activeMs += delta
|
|
@@ -134,6 +155,23 @@ export class TimeTracker {
|
|
|
134
155
|
s.lastActivity = now
|
|
135
156
|
}
|
|
136
157
|
|
|
158
|
+
private async rollover(tabId: string): Promise<void> {
|
|
159
|
+
if (this.rollingOver.has(tabId)) return
|
|
160
|
+
const s = this.sessions.get(tabId)
|
|
161
|
+
if (!s || s.ended) return
|
|
162
|
+
this.rollingOver.add(tabId)
|
|
163
|
+
const { cwd, agentName, idleTimeoutMin } = s
|
|
164
|
+
log(`Day rolled over for tab=${tabId}; ending session and starting fresh`)
|
|
165
|
+
try {
|
|
166
|
+
await this.endSession(tabId)
|
|
167
|
+
await this.startSession(tabId, cwd, agentName, idleTimeoutMin)
|
|
168
|
+
} catch (err: any) {
|
|
169
|
+
log(`rollover failed: ${err?.message || err}`)
|
|
170
|
+
} finally {
|
|
171
|
+
this.rollingOver.delete(tabId)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
137
175
|
async endSession(tabId: string): Promise<void> {
|
|
138
176
|
const s = this.sessions.get(tabId)
|
|
139
177
|
if (!s || s.ended) return
|
|
@@ -159,7 +197,14 @@ export class TimeTracker {
|
|
|
159
197
|
try {
|
|
160
198
|
await this.writeRow(s, Date.now())
|
|
161
199
|
} catch (err: any) {
|
|
162
|
-
log(`checkpoint failed: ${err?.message || err}`)
|
|
200
|
+
log(`checkpoint failed: ${err?.message || err}; retrying in 2s`)
|
|
201
|
+
setTimeout(() => {
|
|
202
|
+
const live = this.sessions.get(tabId)
|
|
203
|
+
if (!live || live.ended) return
|
|
204
|
+
this.writeRow(live, Date.now()).catch((err2: any) => {
|
|
205
|
+
log(`checkpoint retry failed: ${err2?.message || err2}`)
|
|
206
|
+
})
|
|
207
|
+
}, 2000)
|
|
163
208
|
}
|
|
164
209
|
}
|
|
165
210
|
|
|
@@ -167,6 +212,7 @@ export class TimeTracker {
|
|
|
167
212
|
const activeMin = Math.round(s.activeMs / 60000)
|
|
168
213
|
await this.api.updateRow(s.blockId, s.rowId, {
|
|
169
214
|
'Active Time': activeMin,
|
|
215
|
+
'Last Updated': new Date().toISOString(),
|
|
170
216
|
})
|
|
171
217
|
}
|
|
172
218
|
|
package/src/main/workerWs.ts
CHANGED
|
@@ -18,7 +18,7 @@ export interface WorkerRegistration {
|
|
|
18
18
|
cwd: string
|
|
19
19
|
repo_url?: string
|
|
20
20
|
agent: string
|
|
21
|
-
fingerprint
|
|
21
|
+
fingerprint?: string
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export interface WorkerWsEvents {
|
|
@@ -135,6 +135,12 @@ export class WorkerWsClient {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
sendChatLog(entry: { type: string; content: string; ts?: string }): void {
|
|
138
|
+
log('[worker-ws] chat_log DEBUG', {
|
|
139
|
+
type: entry.type,
|
|
140
|
+
chars: entry.content.length,
|
|
141
|
+
lines: entry.content.split('\n').length,
|
|
142
|
+
preview: entry.content.slice(0, 500),
|
|
143
|
+
})
|
|
138
144
|
this.send({ type: 'chat_log', entry })
|
|
139
145
|
}
|
|
140
146
|
|
package/src/preload/index.ts
CHANGED
|
@@ -46,6 +46,15 @@ const api = {
|
|
|
46
46
|
ipcRenderer.invoke('app:cwd'),
|
|
47
47
|
browseCwd: (): Promise<string | null> =>
|
|
48
48
|
ipcRenderer.invoke('app:browseCwd'),
|
|
49
|
+
getVersion: (): Promise<string> =>
|
|
50
|
+
ipcRenderer.invoke('app:getVersion'),
|
|
51
|
+
getUpdateInfo: (): Promise<{ current: string; latest: string | null; hasUpdate: boolean; checkedAt: number | null }> =>
|
|
52
|
+
ipcRenderer.invoke('app:getUpdateInfo'),
|
|
53
|
+
onUpdateInfo: (callback: (info: { current: string; latest: string | null; hasUpdate: boolean; checkedAt: number | null }) => void) => {
|
|
54
|
+
const listener = (_event: Electron.IpcRendererEvent, info: any) => callback(info)
|
|
55
|
+
ipcRenderer.on('app:updateInfo', listener)
|
|
56
|
+
return () => ipcRenderer.removeListener('app:updateInfo', listener)
|
|
57
|
+
},
|
|
49
58
|
|
|
50
59
|
// Settings (legacy)
|
|
51
60
|
getSetting: (key: string): Promise<string | null> =>
|
package/src/renderer/App.tsx
CHANGED
|
@@ -59,10 +59,20 @@ declare global {
|
|
|
59
59
|
readDir: (dirPath: string) => Promise<Array<{ name: string; path: string; isDirectory: boolean }>>
|
|
60
60
|
readFile: (filePath: string) => Promise<{ ok: boolean; content?: string; error?: string }>
|
|
61
61
|
writeFile: (filePath: string, content: string) => Promise<{ ok: boolean; error?: string }>
|
|
62
|
+
getVersion: () => Promise<string>
|
|
63
|
+
getUpdateInfo: () => Promise<UpdateInfo>
|
|
64
|
+
onUpdateInfo: (callback: (info: UpdateInfo) => void) => () => void
|
|
62
65
|
}
|
|
63
66
|
}
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
interface UpdateInfo {
|
|
70
|
+
current: string
|
|
71
|
+
latest: string | null
|
|
72
|
+
hasUpdate: boolean
|
|
73
|
+
checkedAt: number | null
|
|
74
|
+
}
|
|
75
|
+
|
|
66
76
|
const DEFAULT_LAYOUT: LayoutNode = {
|
|
67
77
|
type: 'split',
|
|
68
78
|
direction: 'horizontal',
|
|
@@ -95,6 +105,7 @@ export default function App() {
|
|
|
95
105
|
const [showSettings, setShowSettings] = useState(false)
|
|
96
106
|
const [wsStatus, setWsStatus] = useState('disconnected')
|
|
97
107
|
const [cwd, setCwd] = useState<string | null>(null)
|
|
108
|
+
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
|
98
109
|
|
|
99
110
|
// Multi-tab state
|
|
100
111
|
const [tabs, setTabs] = useState<TabInfo[]>(() => {
|
|
@@ -130,6 +141,15 @@ export default function App() {
|
|
|
130
141
|
return unsub
|
|
131
142
|
}, [])
|
|
132
143
|
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
let cancelled = false
|
|
146
|
+
window.worker.getUpdateInfo().then(info => {
|
|
147
|
+
if (!cancelled) setUpdateInfo(info)
|
|
148
|
+
}).catch(() => { /* ignore */ })
|
|
149
|
+
const unsub = window.worker.onUpdateInfo((info) => setUpdateInfo(info))
|
|
150
|
+
return () => { cancelled = true; unsub() }
|
|
151
|
+
}, [])
|
|
152
|
+
|
|
133
153
|
useEffect(() => {
|
|
134
154
|
const unsub = window.worker.onWorkerMessage((message) => {
|
|
135
155
|
console.log('[worker] Received message:', message)
|
|
@@ -433,7 +453,7 @@ export default function App() {
|
|
|
433
453
|
)}
|
|
434
454
|
</div>
|
|
435
455
|
|
|
436
|
-
<StatusBar wsStatus={wsStatus} cwd={cwd} onChangeCwd={handleChangeCwd} />
|
|
456
|
+
<StatusBar wsStatus={wsStatus} cwd={cwd} onChangeCwd={handleChangeCwd} updateInfo={updateInfo} />
|
|
437
457
|
<SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
|
|
438
458
|
|
|
439
459
|
{showAgentPicker && agents.length > 0 && (
|