@phenx-inc/ctlsurf 0.1.21 → 0.3.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 +409 -99
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +419 -77
- package/out/preload/index.js +12 -8
- package/out/renderer/assets/{cssMode-C6bY9C4O.js → cssMode-DiOmyihM.js} +3 -3
- package/out/renderer/assets/{freemarker2-CkAJiX1K.js → freemarker2-BAfv60yb.js} +1 -1
- package/out/renderer/assets/{handlebars-DnLXVUXp.js → handlebars-Ult17NzQ.js} +1 -1
- package/out/renderer/assets/{html-Ds5-qvDh.js → html-DCxh4J-1.js} +1 -1
- package/out/renderer/assets/{htmlMode-DYFYy4MK.js → htmlMode-CQ5Xenrg.js} +3 -3
- package/out/renderer/assets/{index-DwSsD_Xm.js → index-BnCJ1IaZ.js} +308 -101
- package/out/renderer/assets/{index-DK9wLFFm.css → index-CrTu3Z4M.css} +132 -0
- package/out/renderer/assets/{javascript-CiHhG2a9.js → javascript-U5dsRcHx.js} +2 -2
- package/out/renderer/assets/{jsonMode-DdDRlbXP.js → jsonMode-DshPNyVy.js} +3 -3
- package/out/renderer/assets/{liquid-BP5mb-uD.js → liquid-jHHLYTlB.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-Dljhj5Gh.js → lspLanguageFeatures-CUafmPGy.js} +1 -1
- package/out/renderer/assets/{mdx-D4u3N7dt.js → mdx-Ct-tiY6g.js} +1 -1
- package/out/renderer/assets/{python-BQDHXVwp.js → python-wD3UwKPV.js} +1 -1
- package/out/renderer/assets/{razor-BfXW9cDc.js → razor-11ECS4oH.js} +1 -1
- package/out/renderer/assets/{tsMode-BGTjG8Ow.js → tsMode-D-7JexQ_.js} +1 -1
- package/out/renderer/assets/{typescript-422MU_YO.js → typescript-Cvna1mak.js} +1 -1
- package/out/renderer/assets/{xml-B6EKhHiy.js → xml-JsEaImjA.js} +1 -1
- package/out/renderer/assets/{yaml-LkO_eGYb.js → yaml-B8pCNDb_.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/ctlsurfApi.ts +26 -0
- package/src/main/headless.ts +40 -34
- package/src/main/index.ts +95 -13
- package/src/main/orchestrator.ts +160 -55
- package/src/main/timeTracker.ts +223 -0
- package/src/main/tui.ts +25 -5
- package/src/preload/index.ts +23 -15
- package/src/renderer/App.tsx +197 -43
- package/src/renderer/components/SettingsDialog.tsx +38 -1
- package/src/renderer/components/TerminalPanel.tsx +109 -59
- package/src/renderer/styles.css +132 -0
package/src/main/orchestrator.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { AgentConfig, isCodingAgent } from './agents'
|
|
|
7
7
|
import { CtlsurfApi } from './ctlsurfApi'
|
|
8
8
|
import { ConversationBridge } from './bridge'
|
|
9
9
|
import { WorkerWsClient, type WorkerWsStatus, type IncomingMessage } from './workerWs'
|
|
10
|
+
import { TimeTracker } from './timeTracker'
|
|
10
11
|
|
|
11
12
|
function log(...args: unknown[]): void {
|
|
12
13
|
try { console.log(...args) } catch { /* EPIPE safe */ }
|
|
@@ -19,8 +20,12 @@ export interface Profile {
|
|
|
19
20
|
apiKey: string
|
|
20
21
|
baseUrl: string
|
|
21
22
|
dataspacePageId: string
|
|
23
|
+
trackTime?: boolean
|
|
24
|
+
idleTimeoutMin?: number
|
|
22
25
|
}
|
|
23
26
|
|
|
27
|
+
const DEFAULT_IDLE_TIMEOUT_MIN = 15
|
|
28
|
+
|
|
24
29
|
export interface SettingsData {
|
|
25
30
|
activeProfile: string
|
|
26
31
|
profiles: Record<string, Profile>
|
|
@@ -30,14 +35,22 @@ export interface SettingsData {
|
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export interface OrchestratorEvents {
|
|
33
|
-
onPtyData: (data: string) => void
|
|
34
|
-
onPtyExit: (code: number) => void
|
|
38
|
+
onPtyData: (tabId: string, data: string) => void
|
|
39
|
+
onPtyExit: (tabId: string, code: number) => void
|
|
35
40
|
onWorkerStatus: (status: string) => void
|
|
36
41
|
onWorkerMessage: (message: IncomingMessage) => void
|
|
37
42
|
onWorkerRegistered: (data: { worker_id: string; folder_id: string | null; status: string }) => void
|
|
38
43
|
onCwdChanged: () => void
|
|
39
44
|
}
|
|
40
45
|
|
|
46
|
+
interface TabState {
|
|
47
|
+
ptyManager: PtyManager
|
|
48
|
+
agent: AgentConfig
|
|
49
|
+
cwd: string
|
|
50
|
+
termStreamBuffer: string
|
|
51
|
+
termStreamTimer: ReturnType<typeof setTimeout> | null
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
// ─── Orchestrator ─────────────────────────────────
|
|
42
55
|
|
|
43
56
|
const DEFAULT_PROFILES: Record<string, Profile> = {
|
|
@@ -46,6 +59,8 @@ const DEFAULT_PROFILES: Record<string, Profile> = {
|
|
|
46
59
|
apiKey: '',
|
|
47
60
|
baseUrl: 'https://app.ctlsurf.com',
|
|
48
61
|
dataspacePageId: '',
|
|
62
|
+
trackTime: true,
|
|
63
|
+
idleTimeoutMin: 15,
|
|
49
64
|
},
|
|
50
65
|
}
|
|
51
66
|
|
|
@@ -59,9 +74,11 @@ export class Orchestrator {
|
|
|
59
74
|
readonly ctlsurfApi = new CtlsurfApi()
|
|
60
75
|
readonly bridge = new ConversationBridge()
|
|
61
76
|
readonly workerWs: WorkerWsClient
|
|
77
|
+
readonly timeTracker = new TimeTracker(this.ctlsurfApi)
|
|
62
78
|
|
|
63
79
|
// State
|
|
64
|
-
private
|
|
80
|
+
private tabs = new Map<string, TabState>()
|
|
81
|
+
private activeTabId: string | null = null
|
|
65
82
|
private currentAgent: AgentConfig | null = null
|
|
66
83
|
private currentCwd: string | null = null
|
|
67
84
|
private settings: SettingsData = {
|
|
@@ -69,10 +86,6 @@ export class Orchestrator {
|
|
|
69
86
|
profiles: { ...DEFAULT_PROFILES },
|
|
70
87
|
}
|
|
71
88
|
|
|
72
|
-
// Terminal stream batching
|
|
73
|
-
private termStreamBuffer = ''
|
|
74
|
-
private termStreamTimer: ReturnType<typeof setTimeout> | null = null
|
|
75
|
-
|
|
76
89
|
constructor(settingsDir: string, events: OrchestratorEvents) {
|
|
77
90
|
this.settingsDir = settingsDir
|
|
78
91
|
this.events = events
|
|
@@ -88,8 +101,9 @@ export class Orchestrator {
|
|
|
88
101
|
this.workerWs.sendAck(message.id)
|
|
89
102
|
|
|
90
103
|
if (message.type === 'prompt' || message.type === 'task_dispatch') {
|
|
91
|
-
|
|
92
|
-
|
|
104
|
+
const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null
|
|
105
|
+
if (activeTab) {
|
|
106
|
+
activeTab.ptyManager.write(message.content + '\r')
|
|
93
107
|
this.bridge.feedInput(message.content)
|
|
94
108
|
}
|
|
95
109
|
}
|
|
@@ -102,7 +116,8 @@ export class Orchestrator {
|
|
|
102
116
|
}
|
|
103
117
|
},
|
|
104
118
|
onTerminalInput: (data: string) => {
|
|
105
|
-
this.
|
|
119
|
+
const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null
|
|
120
|
+
activeTab?.ptyManager.write(data)
|
|
106
121
|
},
|
|
107
122
|
})
|
|
108
123
|
|
|
@@ -215,6 +230,8 @@ export class Orchestrator {
|
|
|
215
230
|
baseUrl: p.baseUrl,
|
|
216
231
|
hasApiKey: !!p.apiKey,
|
|
217
232
|
dataspacePageId: p.dataspacePageId || null,
|
|
233
|
+
trackTime: p.trackTime !== false,
|
|
234
|
+
idleTimeoutMin: p.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN,
|
|
218
235
|
})),
|
|
219
236
|
}
|
|
220
237
|
}
|
|
@@ -228,16 +245,27 @@ export class Orchestrator {
|
|
|
228
245
|
baseUrl: p.baseUrl,
|
|
229
246
|
hasApiKey: !!p.apiKey,
|
|
230
247
|
dataspacePageId: p.dataspacePageId || '',
|
|
248
|
+
trackTime: p.trackTime !== false,
|
|
249
|
+
idleTimeoutMin: p.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN,
|
|
231
250
|
}
|
|
232
251
|
}
|
|
233
252
|
|
|
234
|
-
saveProfile(profileId: string, data: {
|
|
253
|
+
saveProfile(profileId: string, data: {
|
|
254
|
+
name: string
|
|
255
|
+
apiKey?: string
|
|
256
|
+
baseUrl: string
|
|
257
|
+
dataspacePageId: string
|
|
258
|
+
trackTime?: boolean
|
|
259
|
+
idleTimeoutMin?: number
|
|
260
|
+
}) {
|
|
235
261
|
const existing = this.settings.profiles[profileId]
|
|
236
262
|
this.settings.profiles[profileId] = {
|
|
237
263
|
name: data.name,
|
|
238
264
|
apiKey: data.apiKey !== undefined ? data.apiKey : (existing?.apiKey || ''),
|
|
239
265
|
baseUrl: data.baseUrl || 'https://app.ctlsurf.com',
|
|
240
266
|
dataspacePageId: data.dataspacePageId || '',
|
|
267
|
+
trackTime: data.trackTime !== undefined ? data.trackTime : (existing?.trackTime !== false),
|
|
268
|
+
idleTimeoutMin: data.idleTimeoutMin !== undefined ? data.idleTimeoutMin : (existing?.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN),
|
|
241
269
|
}
|
|
242
270
|
this.saveSettings()
|
|
243
271
|
|
|
@@ -280,41 +308,65 @@ export class Orchestrator {
|
|
|
280
308
|
return { ok: true }
|
|
281
309
|
}
|
|
282
310
|
|
|
283
|
-
// ─── PTY & Agent
|
|
311
|
+
// ─── PTY & Agent (multi-tab) ─────────────────────
|
|
284
312
|
|
|
285
|
-
async spawnAgent(agent: AgentConfig, cwd: string): Promise<void> {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
313
|
+
async spawnAgent(tabId: string, agent: AgentConfig, cwd: string, opts?: { trackTime?: boolean }): Promise<void> {
|
|
314
|
+
// Kill existing PTY on this tab if any
|
|
315
|
+
const existing = this.tabs.get(tabId)
|
|
316
|
+
if (existing) {
|
|
317
|
+
if (existing.termStreamTimer) clearTimeout(existing.termStreamTimer)
|
|
318
|
+
existing.ptyManager.kill()
|
|
319
|
+
this.tabs.delete(tabId)
|
|
289
320
|
}
|
|
290
321
|
|
|
291
322
|
this.currentAgent = agent
|
|
292
323
|
const prevCwd = this.currentCwd
|
|
293
324
|
this.currentCwd = cwd
|
|
325
|
+
this.activeTabId = tabId
|
|
294
326
|
if (prevCwd !== cwd) {
|
|
295
327
|
this.events.onCwdChanged()
|
|
296
328
|
}
|
|
297
329
|
|
|
298
|
-
|
|
330
|
+
const ptyManager = new PtyManager(agent, cwd)
|
|
331
|
+
const tab: TabState = { ptyManager, agent, cwd, termStreamBuffer: '', termStreamTimer: null }
|
|
332
|
+
this.tabs.set(tabId, tab)
|
|
299
333
|
|
|
300
|
-
|
|
301
|
-
this.events.onPtyData(data)
|
|
302
|
-
this.
|
|
303
|
-
this.
|
|
334
|
+
ptyManager.onData((data: string) => {
|
|
335
|
+
this.events.onPtyData(tabId, data)
|
|
336
|
+
this.timeTracker.recordActivity(tabId)
|
|
337
|
+
if (tabId === this.activeTabId) {
|
|
338
|
+
this.bridge.feedOutput(data)
|
|
339
|
+
this.streamTerminalData(tabId, data)
|
|
340
|
+
}
|
|
304
341
|
})
|
|
305
342
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
this.
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
343
|
+
ptyManager.onExit(async (exitCode: number) => {
|
|
344
|
+
this.events.onPtyExit(tabId, exitCode)
|
|
345
|
+
await this.timeTracker.endSession(tabId)
|
|
346
|
+
if (tabId === this.activeTabId) {
|
|
347
|
+
this.bridge.endSession()
|
|
348
|
+
if (this.currentAgent && isCodingAgent(this.currentAgent)) {
|
|
349
|
+
this.workerWs.disconnect()
|
|
350
|
+
}
|
|
313
351
|
}
|
|
352
|
+
// Clean up tab state
|
|
353
|
+
const t = this.tabs.get(tabId)
|
|
354
|
+
if (t?.termStreamTimer) clearTimeout(t.termStreamTimer)
|
|
314
355
|
})
|
|
315
356
|
|
|
316
357
|
this.bridge.startSession()
|
|
317
358
|
|
|
359
|
+
const profile = this.getActiveProfile()
|
|
360
|
+
const shouldTrack = opts?.trackTime !== undefined ? opts.trackTime : (profile.trackTime !== false)
|
|
361
|
+
if (shouldTrack) {
|
|
362
|
+
void this.timeTracker.startSession(
|
|
363
|
+
tabId,
|
|
364
|
+
cwd,
|
|
365
|
+
agent.name,
|
|
366
|
+
profile.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN,
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
318
370
|
if (isCodingAgent(agent)) {
|
|
319
371
|
this.connectWorkerWs(agent, cwd)
|
|
320
372
|
} else {
|
|
@@ -323,23 +375,74 @@ export class Orchestrator {
|
|
|
323
375
|
}
|
|
324
376
|
}
|
|
325
377
|
|
|
326
|
-
writePty(data: string): void {
|
|
327
|
-
this.ptyManager
|
|
328
|
-
this.
|
|
378
|
+
writePty(tabId: string, data: string): void {
|
|
379
|
+
this.tabs.get(tabId)?.ptyManager.write(data)
|
|
380
|
+
if (tabId === this.activeTabId) {
|
|
381
|
+
this.bridge.feedInput(data)
|
|
382
|
+
}
|
|
329
383
|
}
|
|
330
384
|
|
|
331
|
-
resizePty(cols: number, rows: number): void {
|
|
332
|
-
this.ptyManager
|
|
333
|
-
this.
|
|
334
|
-
|
|
385
|
+
resizePty(tabId: string, cols: number, rows: number): void {
|
|
386
|
+
this.tabs.get(tabId)?.ptyManager.resize(cols, rows)
|
|
387
|
+
if (tabId === this.activeTabId) {
|
|
388
|
+
this.bridge.resize(cols, rows)
|
|
389
|
+
this.workerWs.sendTerminalResize(cols, rows)
|
|
390
|
+
}
|
|
335
391
|
}
|
|
336
392
|
|
|
337
|
-
async
|
|
338
|
-
this.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
393
|
+
async killTab(tabId: string): Promise<void> {
|
|
394
|
+
const tab = this.tabs.get(tabId)
|
|
395
|
+
if (!tab) return
|
|
396
|
+
if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer)
|
|
397
|
+
await this.timeTracker.endSession(tabId)
|
|
398
|
+
tab.ptyManager.kill()
|
|
399
|
+
this.tabs.delete(tabId)
|
|
400
|
+
if (tabId === this.activeTabId) {
|
|
401
|
+
this.bridge.endSession()
|
|
402
|
+
if (isCodingAgent(tab.agent)) {
|
|
403
|
+
this.workerWs.disconnect()
|
|
404
|
+
}
|
|
405
|
+
// Switch active to another tab if available
|
|
406
|
+
const remaining = [...this.tabs.keys()]
|
|
407
|
+
this.activeTabId = remaining.length > 0 ? remaining[remaining.length - 1] : null
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
setActiveTab(tabId: string): void {
|
|
412
|
+
this.activeTabId = tabId
|
|
413
|
+
const tab = this.tabs.get(tabId)
|
|
414
|
+
if (tab) {
|
|
415
|
+
this.currentAgent = tab.agent
|
|
416
|
+
this.currentCwd = tab.cwd
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
getTabIds(): string[] {
|
|
421
|
+
return [...this.tabs.keys()]
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ─── Tracking control (active tab) ──────────────
|
|
425
|
+
|
|
426
|
+
isActiveTabTracking(): boolean {
|
|
427
|
+
if (!this.activeTabId) return false
|
|
428
|
+
return this.timeTracker.isTracking(this.activeTabId)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async setActiveTabTracking(enabled: boolean): Promise<void> {
|
|
432
|
+
if (!this.activeTabId) return
|
|
433
|
+
const tab = this.tabs.get(this.activeTabId)
|
|
434
|
+
if (!tab) return
|
|
435
|
+
if (enabled) {
|
|
436
|
+
if (this.timeTracker.isTracking(this.activeTabId)) return
|
|
437
|
+
const profile = this.getActiveProfile()
|
|
438
|
+
await this.timeTracker.startSession(
|
|
439
|
+
this.activeTabId,
|
|
440
|
+
tab.cwd,
|
|
441
|
+
tab.agent.name,
|
|
442
|
+
profile.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN,
|
|
443
|
+
)
|
|
444
|
+
} else {
|
|
445
|
+
await this.timeTracker.endSession(this.activeTabId)
|
|
343
446
|
}
|
|
344
447
|
}
|
|
345
448
|
|
|
@@ -375,15 +478,17 @@ export class Orchestrator {
|
|
|
375
478
|
}
|
|
376
479
|
}
|
|
377
480
|
|
|
378
|
-
private streamTerminalData(data: string): void {
|
|
379
|
-
this.
|
|
380
|
-
if (!
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
481
|
+
private streamTerminalData(tabId: string, data: string): void {
|
|
482
|
+
const tab = this.tabs.get(tabId)
|
|
483
|
+
if (!tab) return
|
|
484
|
+
tab.termStreamBuffer += data
|
|
485
|
+
if (!tab.termStreamTimer) {
|
|
486
|
+
tab.termStreamTimer = setTimeout(() => {
|
|
487
|
+
if (tab.termStreamBuffer) {
|
|
488
|
+
this.workerWs.sendTerminalData(tab.termStreamBuffer)
|
|
489
|
+
tab.termStreamBuffer = ''
|
|
385
490
|
}
|
|
386
|
-
|
|
491
|
+
tab.termStreamTimer = null
|
|
387
492
|
}, TERM_STREAM_INTERVAL_MS)
|
|
388
493
|
}
|
|
389
494
|
}
|
|
@@ -392,12 +497,12 @@ export class Orchestrator {
|
|
|
392
497
|
|
|
393
498
|
async shutdown(): Promise<void> {
|
|
394
499
|
this.bridge.endSession()
|
|
395
|
-
this.
|
|
396
|
-
this.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
clearTimeout(this.termStreamTimer)
|
|
400
|
-
this.termStreamTimer = null
|
|
500
|
+
await this.timeTracker.endAll()
|
|
501
|
+
for (const [, tab] of this.tabs) {
|
|
502
|
+
if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer)
|
|
503
|
+
tab.ptyManager.kill()
|
|
401
504
|
}
|
|
505
|
+
this.tabs.clear()
|
|
506
|
+
this.workerWs.disconnect()
|
|
402
507
|
}
|
|
403
508
|
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import os from 'os'
|
|
2
|
+
import { randomUUID } from 'crypto'
|
|
3
|
+
import type { CtlsurfApi } from './ctlsurfApi'
|
|
4
|
+
|
|
5
|
+
const DATASTORE_TITLE = 'Time Tracking'
|
|
6
|
+
const AGENT_DATASTORE_PAGE_TITLE = 'Agent Datastore'
|
|
7
|
+
const FIRST_CHECKPOINT_DELAY_MS = 30 * 1000
|
|
8
|
+
const CHECKPOINT_INTERVAL_MS = 5 * 60 * 1000
|
|
9
|
+
|
|
10
|
+
const COLUMNS: Array<{ name: string; type: string }> = [
|
|
11
|
+
{ name: 'Started', type: 'date' },
|
|
12
|
+
{ name: 'Active Time', type: 'number' },
|
|
13
|
+
{ name: 'Agent', type: 'text' },
|
|
14
|
+
{ name: 'Worker', type: 'text' },
|
|
15
|
+
{ name: 'Session', type: 'text' },
|
|
16
|
+
{ name: 'Notes', type: 'text' },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
interface SessionState {
|
|
20
|
+
blockId: string
|
|
21
|
+
rowId: string
|
|
22
|
+
cwd: string
|
|
23
|
+
startedAt: number
|
|
24
|
+
lastActivity: number
|
|
25
|
+
activeMs: number
|
|
26
|
+
idleTimeoutMs: number
|
|
27
|
+
firstCheckpointTimer: ReturnType<typeof setTimeout> | null
|
|
28
|
+
checkpointTimer: ReturnType<typeof setInterval> | null
|
|
29
|
+
ended: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function log(...args: unknown[]): void {
|
|
33
|
+
try { console.log('[time-tracker]', ...args) } catch { /* EPIPE safe */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function findPageByTitle(pages: any[], title: string): any | null {
|
|
37
|
+
for (const p of pages) {
|
|
38
|
+
if (p?.title === title) return p
|
|
39
|
+
if (p?.children?.length) {
|
|
40
|
+
const c = findPageByTitle(p.children, title)
|
|
41
|
+
if (c) return c
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class TimeTracker {
|
|
48
|
+
private api: CtlsurfApi
|
|
49
|
+
private sessions = new Map<string, SessionState>()
|
|
50
|
+
private blockCache = new Map<string, string>()
|
|
51
|
+
|
|
52
|
+
constructor(api: CtlsurfApi) {
|
|
53
|
+
this.api = api
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async startSession(tabId: string, cwd: string, agentName: string, idleTimeoutMin: number): Promise<void> {
|
|
57
|
+
if (this.sessions.has(tabId)) {
|
|
58
|
+
await this.endSession(tabId)
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const blockId = await this.ensureDatastore(cwd)
|
|
62
|
+
if (!blockId) {
|
|
63
|
+
log(`No "${AGENT_DATASTORE_PAGE_TITLE}" page found for ${cwd} — tracking disabled for this session`)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const startedAt = Date.now()
|
|
67
|
+
const startedIso = new Date(startedAt).toISOString()
|
|
68
|
+
const sessionUuid = randomUUID()
|
|
69
|
+
const row = await this.api.addRow(blockId, {
|
|
70
|
+
Started: startedIso,
|
|
71
|
+
'Active Time': 0,
|
|
72
|
+
Agent: agentName,
|
|
73
|
+
Worker: os.hostname(),
|
|
74
|
+
Session: sessionUuid,
|
|
75
|
+
Notes: '',
|
|
76
|
+
})
|
|
77
|
+
const rowId = row?.id
|
|
78
|
+
if (!rowId) {
|
|
79
|
+
log('addRow returned no id; aborting tracking', row)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
const state: SessionState = {
|
|
83
|
+
blockId,
|
|
84
|
+
rowId,
|
|
85
|
+
cwd,
|
|
86
|
+
startedAt,
|
|
87
|
+
lastActivity: startedAt,
|
|
88
|
+
activeMs: 0,
|
|
89
|
+
idleTimeoutMs: Math.max(1, idleTimeoutMin) * 60 * 1000,
|
|
90
|
+
firstCheckpointTimer: null,
|
|
91
|
+
checkpointTimer: null,
|
|
92
|
+
ended: false,
|
|
93
|
+
}
|
|
94
|
+
state.firstCheckpointTimer = setTimeout(() => {
|
|
95
|
+
void this.checkpoint(tabId)
|
|
96
|
+
const live = this.sessions.get(tabId)
|
|
97
|
+
if (live && !live.ended) {
|
|
98
|
+
live.checkpointTimer = setInterval(() => {
|
|
99
|
+
void this.checkpoint(tabId)
|
|
100
|
+
}, CHECKPOINT_INTERVAL_MS)
|
|
101
|
+
}
|
|
102
|
+
}, FIRST_CHECKPOINT_DELAY_MS)
|
|
103
|
+
this.sessions.set(tabId, state)
|
|
104
|
+
log(`Started tracking tab=${tabId} agent="${agentName}" cwd=${cwd}`)
|
|
105
|
+
} catch (err: any) {
|
|
106
|
+
log(`startSession failed: ${err?.message || err}`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
isTracking(tabId: string): boolean {
|
|
111
|
+
const s = this.sessions.get(tabId)
|
|
112
|
+
return !!s && !s.ended
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
recordActivity(tabId: string): void {
|
|
116
|
+
const s = this.sessions.get(tabId)
|
|
117
|
+
if (!s || s.ended) return
|
|
118
|
+
const now = Date.now()
|
|
119
|
+
const delta = now - s.lastActivity
|
|
120
|
+
if (delta < s.idleTimeoutMs) {
|
|
121
|
+
s.activeMs += delta
|
|
122
|
+
}
|
|
123
|
+
s.lastActivity = now
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async endSession(tabId: string): Promise<void> {
|
|
127
|
+
const s = this.sessions.get(tabId)
|
|
128
|
+
if (!s || s.ended) return
|
|
129
|
+
s.ended = true
|
|
130
|
+
if (s.firstCheckpointTimer) clearTimeout(s.firstCheckpointTimer)
|
|
131
|
+
if (s.checkpointTimer) clearInterval(s.checkpointTimer)
|
|
132
|
+
try {
|
|
133
|
+
await this.writeRow(s, Date.now())
|
|
134
|
+
} catch (err: any) {
|
|
135
|
+
log(`endSession write failed: ${err?.message || err}`)
|
|
136
|
+
}
|
|
137
|
+
this.sessions.delete(tabId)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async endAll(): Promise<void> {
|
|
141
|
+
const ids = [...this.sessions.keys()]
|
|
142
|
+
await Promise.all(ids.map(id => this.endSession(id)))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async checkpoint(tabId: string): Promise<void> {
|
|
146
|
+
const s = this.sessions.get(tabId)
|
|
147
|
+
if (!s || s.ended) return
|
|
148
|
+
try {
|
|
149
|
+
await this.writeRow(s, Date.now())
|
|
150
|
+
} catch (err: any) {
|
|
151
|
+
log(`checkpoint failed: ${err?.message || err}`)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async writeRow(s: SessionState, _endTimeMs: number): Promise<void> {
|
|
156
|
+
const activeMin = Math.round(s.activeMs / 60000)
|
|
157
|
+
await this.api.updateRow(s.blockId, s.rowId, {
|
|
158
|
+
'Active Time': activeMin,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async ensureDatastore(cwd: string): Promise<string | null> {
|
|
163
|
+
const cached = this.blockCache.get(cwd)
|
|
164
|
+
if (cached) return cached
|
|
165
|
+
|
|
166
|
+
let folder: any = null
|
|
167
|
+
try {
|
|
168
|
+
folder = await this.api.findFolderByPath(cwd)
|
|
169
|
+
} catch {
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
if (!folder?.id) return null
|
|
173
|
+
|
|
174
|
+
const folderDetail = await this.api.getFolder(folder.id)
|
|
175
|
+
const agentPage = findPageByTitle(folderDetail?.pages || [], AGENT_DATASTORE_PAGE_TITLE)
|
|
176
|
+
if (!agentPage?.id) return null
|
|
177
|
+
|
|
178
|
+
const summaries = await this.api.getPageBlockSummaries(agentPage.id)
|
|
179
|
+
const existing = (summaries || []).find((b: any) => b?.type === 'datastore' && b?.title === DATASTORE_TITLE)
|
|
180
|
+
if (existing?.id) {
|
|
181
|
+
await this.ensureColumns(existing.id)
|
|
182
|
+
this.blockCache.set(cwd, existing.id)
|
|
183
|
+
return existing.id
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const columns = COLUMNS.map((c, i) => ({ id: `col_${i}`, name: c.name, type: c.type }))
|
|
187
|
+
const created = await this.api.createBlock(agentPage.id, {
|
|
188
|
+
type: 'datastore',
|
|
189
|
+
title: DATASTORE_TITLE,
|
|
190
|
+
props: { columns },
|
|
191
|
+
})
|
|
192
|
+
if (created?.id) {
|
|
193
|
+
log(`Created "${DATASTORE_TITLE}" datastore on Agent Datastore page for ${cwd}`)
|
|
194
|
+
this.blockCache.set(cwd, created.id)
|
|
195
|
+
return created.id
|
|
196
|
+
}
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private async ensureColumns(blockId: string): Promise<void> {
|
|
201
|
+
try {
|
|
202
|
+
const schema = await this.api.getDatastoreSchema(blockId)
|
|
203
|
+
const existingCols = schema.columns || []
|
|
204
|
+
const existingNames = new Set(existingCols.map(c => c.name))
|
|
205
|
+
const missing = COLUMNS.filter(c => !existingNames.has(c.name))
|
|
206
|
+
if (missing.length === 0) return
|
|
207
|
+
|
|
208
|
+
const usedIds = new Set(existingCols.map(c => c.id))
|
|
209
|
+
let nextIdx = existingCols.length
|
|
210
|
+
const appended = missing.map(c => {
|
|
211
|
+
let id = `col_${nextIdx++}`
|
|
212
|
+
while (usedIds.has(id)) id = `col_${nextIdx++}`
|
|
213
|
+
usedIds.add(id)
|
|
214
|
+
return { id, name: c.name, type: c.type }
|
|
215
|
+
})
|
|
216
|
+
const merged = [...existingCols, ...appended]
|
|
217
|
+
await this.api.updateDatastoreSchema(blockId, merged)
|
|
218
|
+
log(`Added ${missing.length} missing column(s) to existing Time Tracking datastore: ${missing.map(c => c.name).join(', ')}`)
|
|
219
|
+
} catch (err: any) {
|
|
220
|
+
log(`ensureColumns failed: ${err?.message || err}`)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/main/tui.ts
CHANGED
|
@@ -132,11 +132,16 @@ export class Tui {
|
|
|
132
132
|
* Show an interactive agent picker modal.
|
|
133
133
|
* Uses alternate screen just for the picker, then exits back to normal.
|
|
134
134
|
*/
|
|
135
|
-
showAgentPicker(
|
|
135
|
+
showAgentPicker(
|
|
136
|
+
agents: { name: string; description: string }[],
|
|
137
|
+
options: { initialTrackTime: boolean },
|
|
138
|
+
): Promise<{ agentIdx: number; trackTime: boolean }> {
|
|
136
139
|
return new Promise((resolve) => {
|
|
137
140
|
let selected = 0
|
|
141
|
+
let trackTime = options.initialTrackTime
|
|
138
142
|
const modalWidth = 44
|
|
139
|
-
|
|
143
|
+
// +4 for borders/title/sep, +2 for track-time separator + row
|
|
144
|
+
const modalHeight = agents.length + 4 + 2
|
|
140
145
|
const startCol = Math.max(1, Math.floor((this.cols - modalWidth) / 2))
|
|
141
146
|
const startRow = Math.max(1, Math.floor((this.rows - modalHeight) / 2))
|
|
142
147
|
|
|
@@ -180,10 +185,22 @@ export class Tui {
|
|
|
180
185
|
this.write(`${CSI}${row};${startCol}H${bg}${FG_DIM}\u2502${RESET}${bg}${content}${pad}${RESET}${BG_MODAL}${FG_DIM}\u2502${RESET}`)
|
|
181
186
|
}
|
|
182
187
|
|
|
183
|
-
const
|
|
188
|
+
const innerSep = '\u251c' + '\u2500'.repeat(modalWidth - 2) + '┤'
|
|
189
|
+
const sepRow = startRow + 3 + agents.length
|
|
190
|
+
this.write(`${CSI}${sepRow};${startCol}H${BG_MODAL}${FG_DIM}${innerSep}${RESET}`)
|
|
191
|
+
|
|
192
|
+
const trackRow = sepRow + 1
|
|
193
|
+
const checkbox = trackTime ? `${FG_GREEN}[✓]${RESET}${BG_MODAL}` : `${FG_DIM}[ ]${RESET}${BG_MODAL}`
|
|
194
|
+
const trackLabelFg = trackTime ? FG_WHITE : FG_DIM
|
|
195
|
+
const trackContent = ` ${checkbox} ${trackLabelFg}Track time${RESET}${BG_MODAL}`
|
|
196
|
+
const trackContentLen = 2 + 3 + 1 + 'Track time'.length
|
|
197
|
+
const trackPad = ' '.repeat(Math.max(0, modalWidth - 2 - trackContentLen))
|
|
198
|
+
this.write(`${CSI}${trackRow};${startCol}H${BG_MODAL}${FG_DIM}│${RESET}${BG_MODAL}${trackContent}${trackPad}${FG_DIM}│${RESET}`)
|
|
199
|
+
|
|
200
|
+
const botRow = trackRow + 1
|
|
184
201
|
this.write(`${CSI}${botRow};${startCol}H${BG_MODAL}${FG_DIM}${botBorder}${RESET}`)
|
|
185
202
|
|
|
186
|
-
const hint = '\u2191\u2193 navigate \u00B7 Enter select \u00B7 q quit'
|
|
203
|
+
const hint = '\u2191\u2193 navigate \u00B7 Enter select \u00B7 t track \u00B7 q quit'
|
|
187
204
|
const hintCol = Math.max(1, Math.floor((this.cols - hint.length) / 2))
|
|
188
205
|
this.write(`${CSI}${botRow + 2};${hintCol}H${FG_DIM}${hint}${RESET}`)
|
|
189
206
|
}
|
|
@@ -204,9 +221,12 @@ export class Tui {
|
|
|
204
221
|
} else if (key === '\x1b[B' || key === 'j') {
|
|
205
222
|
selected = (selected + 1) % agents.length
|
|
206
223
|
drawModal()
|
|
224
|
+
} else if (key === 't' || key === 'T' || key === ' ') {
|
|
225
|
+
trackTime = !trackTime
|
|
226
|
+
drawModal()
|
|
207
227
|
} else if (key === '\r' || key === '\n') {
|
|
208
228
|
cleanup()
|
|
209
|
-
resolve(selected)
|
|
229
|
+
resolve({ agentIdx: selected, trackTime })
|
|
210
230
|
} else if (key === 'q' || key === '\x1b' || key === '\x03') {
|
|
211
231
|
cleanup()
|
|
212
232
|
this.write(`${CSI}?25h`)
|