@phenx-inc/ctlsurf 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/out/headless/index.mjs +276 -56
  2. package/out/headless/index.mjs.map +3 -3
  3. package/out/main/index.js +152 -94
  4. package/out/preload/index.js +3 -0
  5. package/out/renderer/assets/{cssMode-DQW-brNd.js → cssMode-CYoo4t9f.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-DxgOckH2.js → freemarker2--UQnPZsn.js} +1 -1
  7. package/out/renderer/assets/{handlebars-BX1Wpk_3.js → handlebars-DVDrmX0C.js} +1 -1
  8. package/out/renderer/assets/{html-t-KXioI0.js → html-D1-cXoLy.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-Dya7iUjr.js → htmlMode-f5nBuprq.js} +3 -3
  10. package/out/renderer/assets/{index-D6JBcQ20.css → index-65hyKM_8.css} +16 -0
  11. package/out/renderer/assets/{index-DNqZidnO.js → index-D23nru43.js} +64 -23
  12. package/out/renderer/assets/{javascript-DZzW2adn.js → javascript-CcarFzBL.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-D_Wv7XH8.js → jsonMode-BvF-xK9U.js} +3 -3
  14. package/out/renderer/assets/{liquid-BJAHAm2T.js → liquid-CHLtUKl2.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-BgMd-KJk.js → lspLanguageFeatures-B9aNeatS.js} +1 -1
  16. package/out/renderer/assets/{mdx-B6Zod3ry.js → mdx-HGDrkifZ.js} +1 -1
  17. package/out/renderer/assets/{python-Cgt13-KH.js → python-B_dPzjJ6.js} +1 -1
  18. package/out/renderer/assets/{razor-BcwFJGYS.js → razor-CHheM4ot.js} +1 -1
  19. package/out/renderer/assets/{tsMode-BTjzM6fl.js → tsMode-CdC3i1gG.js} +1 -1
  20. package/out/renderer/assets/{typescript-DZYDQEUb.js → typescript-BX6guVRK.js} +1 -1
  21. package/out/renderer/assets/{xml-CloiUoIW.js → xml-CpS-pOPE.js} +1 -1
  22. package/out/renderer/assets/{yaml-CdKdpE-z.js → yaml-Du0AjOHW.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/src/main/bridge.ts +9 -3
  26. package/src/main/headless.ts +35 -2
  27. package/src/main/index.ts +10 -39
  28. package/src/main/orchestrator.ts +29 -1
  29. package/src/main/timeTracker.ts +74 -45
  30. package/src/main/tui.ts +20 -8
  31. package/src/main/updateCheck.ts +40 -0
  32. package/src/preload/index.ts +6 -0
  33. package/src/renderer/App.tsx +2 -0
  34. package/src/renderer/components/AgentPicker.tsx +38 -3
  35. package/src/renderer/styles.css +16 -0
@@ -30,8 +30,9 @@ function formatStarted(ms: number): string {
30
30
  }
31
31
 
32
32
  interface SessionState {
33
- blockId: string
34
- rowId: string
33
+ blockId: string | null
34
+ rowId: string | null
35
+ sessionUuid: string
35
36
  cwd: string
36
37
  agentName: string
37
38
  idleTimeoutMin: number
@@ -83,55 +84,72 @@ export class TimeTracker {
83
84
  if (this.sessions.has(tabId)) {
84
85
  await this.endSession(tabId)
85
86
  }
86
- try {
87
- const blockId = await this.ensureDatastore(cwd)
88
- if (!blockId) {
89
- log(`No "${AGENT_DATASTORE_PAGE_TITLE}" page found for ${cwd} — tracking disabled for this session`)
90
- return
87
+ const startedAt = Date.now()
88
+ const state: SessionState = {
89
+ blockId: null,
90
+ rowId: null,
91
+ sessionUuid: randomUUID(),
92
+ cwd,
93
+ agentName,
94
+ idleTimeoutMin,
95
+ startedAt,
96
+ lastActivity: startedAt,
97
+ activeMs: 0,
98
+ idleTimeoutMs: Math.max(1, idleTimeoutMin) * 60 * 1000,
99
+ firstCheckpointTimer: null,
100
+ checkpointTimer: null,
101
+ ended: false,
102
+ }
103
+ this.sessions.set(tabId, state)
104
+
105
+ await this.tryResolve(tabId)
106
+
107
+ state.firstCheckpointTimer = setTimeout(() => {
108
+ void this.checkpoint(tabId)
109
+ const live = this.sessions.get(tabId)
110
+ if (live && !live.ended) {
111
+ live.checkpointTimer = setInterval(() => {
112
+ void this.checkpoint(tabId)
113
+ }, CHECKPOINT_INTERVAL_MS)
91
114
  }
92
- const startedAt = Date.now()
93
- const sessionUuid = randomUUID()
115
+ }, FIRST_CHECKPOINT_DELAY_MS)
116
+
117
+ const pending = !state.blockId || !state.rowId
118
+ log(`Started tracking tab=${tabId} agent="${agentName}" cwd=${cwd}${pending ? ' (pending datastore — will retry on each checkpoint)' : ''}`)
119
+ }
120
+
121
+ /** Attempts to locate (or create) the datastore + add the session row.
122
+ * Returns true once the session is resolved (blockId + rowId set).
123
+ * Safe to call repeatedly: re-running while pending will keep retrying;
124
+ * once resolved it's a no-op. */
125
+ private async tryResolve(tabId: string): Promise<boolean> {
126
+ const s = this.sessions.get(tabId)
127
+ if (!s) return false
128
+ if (s.blockId && s.rowId) return true
129
+ try {
130
+ const blockId = await this.ensureDatastore(s.cwd)
131
+ if (!blockId) return false
94
132
  const row = await this.api.addRow(blockId, {
95
- Started: formatStarted(startedAt),
96
- 'Active Time': 0,
97
- 'Last Updated': new Date(startedAt).toISOString(),
98
- Agent: agentName,
133
+ Started: formatStarted(s.startedAt),
134
+ 'Active Time': Math.round(s.activeMs / 60000),
135
+ 'Last Updated': new Date().toISOString(),
136
+ Agent: s.agentName,
99
137
  Worker: os.hostname(),
100
- Session: sessionUuid,
138
+ Session: s.sessionUuid,
101
139
  Notes: '',
102
140
  })
103
141
  const rowId = row?.id
104
142
  if (!rowId) {
105
- log('addRow returned no id; aborting tracking', row)
106
- return
143
+ log(`addRow returned no id for tab=${tabId}; will retry on next checkpoint`)
144
+ return false
107
145
  }
108
- const state: SessionState = {
109
- blockId,
110
- rowId,
111
- cwd,
112
- agentName,
113
- idleTimeoutMin,
114
- startedAt,
115
- lastActivity: startedAt,
116
- activeMs: 0,
117
- idleTimeoutMs: Math.max(1, idleTimeoutMin) * 60 * 1000,
118
- firstCheckpointTimer: null,
119
- checkpointTimer: null,
120
- ended: false,
121
- }
122
- state.firstCheckpointTimer = setTimeout(() => {
123
- void this.checkpoint(tabId)
124
- const live = this.sessions.get(tabId)
125
- if (live && !live.ended) {
126
- live.checkpointTimer = setInterval(() => {
127
- void this.checkpoint(tabId)
128
- }, CHECKPOINT_INTERVAL_MS)
129
- }
130
- }, FIRST_CHECKPOINT_DELAY_MS)
131
- this.sessions.set(tabId, state)
132
- log(`Started tracking tab=${tabId} agent="${agentName}" cwd=${cwd}`)
146
+ s.blockId = blockId
147
+ s.rowId = rowId
148
+ log(`Resolved datastore for tab=${tabId} (cwd=${s.cwd})`)
149
+ return true
133
150
  } catch (err: any) {
134
- log(`startSession failed: ${err?.message || err}`)
151
+ log(`tryResolve failed for tab=${tabId}: ${err?.message || err}`)
152
+ return false
135
153
  }
136
154
  }
137
155
 
@@ -175,14 +193,21 @@ export class TimeTracker {
175
193
  async endSession(tabId: string): Promise<void> {
176
194
  const s = this.sessions.get(tabId)
177
195
  if (!s || s.ended) return
178
- s.ended = true
179
196
  if (s.firstCheckpointTimer) clearTimeout(s.firstCheckpointTimer)
180
197
  if (s.checkpointTimer) clearInterval(s.checkpointTimer)
181
198
  try {
182
- await this.writeRow(s, Date.now())
199
+ if (!s.blockId || !s.rowId) {
200
+ await this.tryResolve(tabId)
201
+ }
202
+ if (s.blockId && s.rowId) {
203
+ await this.writeRow(s, Date.now())
204
+ } else {
205
+ log(`endSession for tab=${tabId}: never resolved datastore; ${Math.round(s.activeMs / 60000)}min not recorded`)
206
+ }
183
207
  } catch (err: any) {
184
208
  log(`endSession write failed: ${err?.message || err}`)
185
209
  }
210
+ s.ended = true
186
211
  this.sessions.delete(tabId)
187
212
  }
188
213
 
@@ -194,13 +219,16 @@ export class TimeTracker {
194
219
  private async checkpoint(tabId: string): Promise<void> {
195
220
  const s = this.sessions.get(tabId)
196
221
  if (!s || s.ended) return
222
+ if (!s.blockId || !s.rowId) {
223
+ if (!(await this.tryResolve(tabId))) return
224
+ }
197
225
  try {
198
226
  await this.writeRow(s, Date.now())
199
227
  } catch (err: any) {
200
228
  log(`checkpoint failed: ${err?.message || err}; retrying in 2s`)
201
229
  setTimeout(() => {
202
230
  const live = this.sessions.get(tabId)
203
- if (!live || live.ended) return
231
+ if (!live || live.ended || !live.blockId || !live.rowId) return
204
232
  this.writeRow(live, Date.now()).catch((err2: any) => {
205
233
  log(`checkpoint retry failed: ${err2?.message || err2}`)
206
234
  })
@@ -209,6 +237,7 @@ export class TimeTracker {
209
237
  }
210
238
 
211
239
  private async writeRow(s: SessionState, _endTimeMs: number): Promise<void> {
240
+ if (!s.blockId || !s.rowId) return
212
241
  const activeMin = Math.round(s.activeMs / 60000)
213
242
  await this.api.updateRow(s.blockId, s.rowId, {
214
243
  'Active Time': activeMin,
package/src/main/tui.ts CHANGED
@@ -134,14 +134,15 @@ export class Tui {
134
134
  */
135
135
  showAgentPicker(
136
136
  agents: { name: string; description: string }[],
137
- options: { initialTrackTime: boolean },
138
- ): Promise<{ agentIdx: number; trackTime: boolean }> {
137
+ options: { initialTrackTime: boolean; initialLogChat: boolean },
138
+ ): Promise<{ agentIdx: number; trackTime: boolean; logChat: boolean }> {
139
139
  return new Promise((resolve) => {
140
140
  let selected = 0
141
141
  let trackTime = options.initialTrackTime
142
+ let logChat = options.initialLogChat
142
143
  const modalWidth = 44
143
- // +4 for borders/title/sep, +2 for track-time separator + row
144
- const modalHeight = agents.length + 4 + 2
144
+ // +4 for borders/title/sep, +3 for separator + track-time row + log-chat row
145
+ const modalHeight = agents.length + 4 + 3
145
146
  const startCol = Math.max(1, Math.floor((this.cols - modalWidth) / 2))
146
147
  const startRow = Math.max(1, Math.floor((this.rows - modalHeight) / 2))
147
148
 
@@ -197,10 +198,18 @@ export class Tui {
197
198
  const trackPad = ' '.repeat(Math.max(0, modalWidth - 2 - trackContentLen))
198
199
  this.write(`${CSI}${trackRow};${startCol}H${BG_MODAL}${FG_DIM}│${RESET}${BG_MODAL}${trackContent}${trackPad}${FG_DIM}│${RESET}`)
199
200
 
200
- const botRow = trackRow + 1
201
+ const logRow = trackRow + 1
202
+ const logCheckbox = logChat ? `${FG_GREEN}[\u2713]${RESET}${BG_MODAL}` : `${FG_DIM}[ ]${RESET}${BG_MODAL}`
203
+ const logLabelFg = logChat ? FG_WHITE : FG_DIM
204
+ const logContent = ` ${logCheckbox} ${logLabelFg}Log chat${RESET}${BG_MODAL}`
205
+ const logContentLen = 2 + 3 + 1 + 'Log chat'.length
206
+ const logPad = ' '.repeat(Math.max(0, modalWidth - 2 - logContentLen))
207
+ this.write(`${CSI}${logRow};${startCol}H${BG_MODAL}${FG_DIM}\u2502${RESET}${BG_MODAL}${logContent}${logPad}${FG_DIM}\u2502${RESET}`)
208
+
209
+ const botRow = logRow + 1
201
210
  this.write(`${CSI}${botRow};${startCol}H${BG_MODAL}${FG_DIM}${botBorder}${RESET}`)
202
211
 
203
- const hint = '\u2191\u2193 navigate \u00B7 Enter select \u00B7 t track \u00B7 q quit'
212
+ const hint = '\u2191\u2193 nav \u00B7 Enter \u00B7 t track \u00B7 l log \u00B7 q quit'
204
213
  const hintCol = Math.max(1, Math.floor((this.cols - hint.length) / 2))
205
214
  this.write(`${CSI}${botRow + 2};${hintCol}H${FG_DIM}${hint}${RESET}`)
206
215
  }
@@ -221,12 +230,15 @@ export class Tui {
221
230
  } else if (key === '\x1b[B' || key === 'j') {
222
231
  selected = (selected + 1) % agents.length
223
232
  drawModal()
224
- } else if (key === 't' || key === 'T' || key === ' ') {
233
+ } else if (key === 't' || key === 'T') {
225
234
  trackTime = !trackTime
226
235
  drawModal()
236
+ } else if (key === 'l' || key === 'L') {
237
+ logChat = !logChat
238
+ drawModal()
227
239
  } else if (key === '\r' || key === '\n') {
228
240
  cleanup()
229
- resolve({ agentIdx: selected, trackTime })
241
+ resolve({ agentIdx: selected, trackTime, logChat })
230
242
  } else if (key === 'q' || key === '\x1b' || key === '\x03') {
231
243
  cleanup()
232
244
  this.write(`${CSI}?25h`)
@@ -0,0 +1,40 @@
1
+ import https from 'https'
2
+
3
+ export const NPM_PACKAGE = '@phenx-inc/ctlsurf'
4
+
5
+ export function compareSemver(a: string, b: string): number {
6
+ const pa = a.split('.').map(n => parseInt(n, 10) || 0)
7
+ const pb = b.split('.').map(n => parseInt(n, 10) || 0)
8
+ for (let i = 0; i < 3; i++) {
9
+ const ai = pa[i] || 0
10
+ const bi = pb[i] || 0
11
+ if (ai !== bi) return ai - bi
12
+ }
13
+ return 0
14
+ }
15
+
16
+ export function fetchLatestNpmVersion(timeoutMs = 8000): Promise<string | null> {
17
+ return new Promise((resolve) => {
18
+ const url = `https://registry.npmjs.org/${encodeURIComponent(NPM_PACKAGE)}/latest`
19
+ const req = https.get(url, { headers: { 'Accept': 'application/json' } }, (res) => {
20
+ if (res.statusCode !== 200) {
21
+ res.resume()
22
+ resolve(null)
23
+ return
24
+ }
25
+ let body = ''
26
+ res.setEncoding('utf8')
27
+ res.on('data', (chunk) => { body += chunk })
28
+ res.on('end', () => {
29
+ try {
30
+ const json = JSON.parse(body)
31
+ resolve(typeof json?.version === 'string' ? json.version : null)
32
+ } catch {
33
+ resolve(null)
34
+ }
35
+ })
36
+ })
37
+ req.on('error', () => resolve(null))
38
+ req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null) })
39
+ })
40
+ }
@@ -82,6 +82,12 @@ const api = {
82
82
  setTracking: (enabled: boolean): Promise<{ active: boolean }> =>
83
83
  ipcRenderer.invoke('tracking:set', enabled),
84
84
 
85
+ // Chat logging (global)
86
+ getLogChat: (): Promise<{ enabled: boolean }> =>
87
+ ipcRenderer.invoke('logchat:get'),
88
+ setLogChat: (enabled: boolean): Promise<{ enabled: boolean }> =>
89
+ ipcRenderer.invoke('logchat:set', enabled),
90
+
85
91
  // Filesystem
86
92
  readDir: (dirPath: string): Promise<Array<{ name: string; path: string; isDirectory: boolean }>> =>
87
93
  ipcRenderer.invoke('fs:readDir', dirPath),
@@ -46,6 +46,8 @@ declare global {
46
46
  deleteProfile: (profileId: string) => Promise<{ ok: boolean }>
47
47
  getTracking: () => Promise<{ active: boolean }>
48
48
  setTracking: (enabled: boolean) => Promise<{ active: boolean }>
49
+ getLogChat: () => Promise<{ enabled: boolean }>
50
+ setLogChat: (enabled: boolean) => Promise<{ enabled: boolean }>
49
51
  createProject: () => Promise<{ ok: boolean; folder_id?: string; error?: string }>
50
52
  getWebviewInfo: () => Promise<{
51
53
  frontendUrl: string; pageUrl?: string; authenticated: boolean;
@@ -1,3 +1,5 @@
1
+ import { useEffect, useState } from 'react'
2
+
1
3
  interface AgentConfig {
2
4
  id: string
3
5
  name: string
@@ -14,9 +16,32 @@ interface AgentPickerProps {
14
16
  }
15
17
 
16
18
  export function AgentPicker({ agents, cwd, onSelect, onChangeCwd }: AgentPickerProps) {
17
- const home = typeof window !== 'undefined' ? '' : ''
18
- // Shorten home dir for display
19
19
  const displayPath = cwd.replace(/^\/Users\/[^/]+/, '~')
20
+ const [logChat, setLogChat] = useState(false)
21
+ const [loaded, setLoaded] = useState(false)
22
+
23
+ useEffect(() => {
24
+ let cancelled = false
25
+ window.worker.getLogChat().then(r => {
26
+ if (cancelled) return
27
+ setLogChat(!!r?.enabled)
28
+ setLoaded(true)
29
+ }).catch(() => setLoaded(true))
30
+ return () => { cancelled = true }
31
+ }, [])
32
+
33
+ const toggleLogChat = async () => {
34
+ const next = !logChat
35
+ setLogChat(next)
36
+ try { await window.worker.setLogChat(next) } catch { /* ignore */ }
37
+ }
38
+
39
+ const handleSelect = async (agent: AgentConfig) => {
40
+ if (loaded) {
41
+ try { await window.worker.setLogChat(logChat) } catch { /* ignore */ }
42
+ }
43
+ onSelect(agent)
44
+ }
20
45
 
21
46
  return (
22
47
  <div className="agent-picker-overlay">
@@ -35,13 +60,23 @@ export function AgentPicker({ agents, cwd, onSelect, onChangeCwd }: AgentPickerP
35
60
  <button
36
61
  key={a.id}
37
62
  className="agent-picker-item"
38
- onClick={() => onSelect(a)}
63
+ onClick={() => handleSelect(a)}
39
64
  >
40
65
  <span className="agent-picker-name">{a.name}</span>
41
66
  <span className="agent-picker-desc">{a.description}</span>
42
67
  </button>
43
68
  ))}
44
69
  </div>
70
+
71
+ <label className="agent-picker-option" onClick={(e) => e.stopPropagation()}>
72
+ <input
73
+ type="checkbox"
74
+ checked={logChat}
75
+ onChange={toggleLogChat}
76
+ />
77
+ <span>Log chat to ctlsurf</span>
78
+ </label>
79
+
45
80
  <div className="agent-picker-hint">Select an agent to start</div>
46
81
  </div>
47
82
  </div>
@@ -821,6 +821,22 @@ html, body, #root {
821
821
  color: #414868;
822
822
  }
823
823
 
824
+ .agent-picker-option {
825
+ margin-top: 16px;
826
+ display: flex;
827
+ align-items: center;
828
+ gap: 8px;
829
+ font-size: 12px;
830
+ color: #a9b1d6;
831
+ cursor: pointer;
832
+ user-select: none;
833
+ }
834
+
835
+ .agent-picker-option input[type="checkbox"] {
836
+ cursor: pointer;
837
+ accent-color: #7aa2f7;
838
+ }
839
+
824
840
  /* Settings dialog */
825
841
  .settings-overlay {
826
842
  position: fixed;