--- jasper/timer.lua --- Timer state machine + inactivity auto-pause. local M = {} local api = require("jasper.api") local auth = require("jasper.auth") --- Internal state (one instance per Neovim session). local state = { token = nil, ---@type string|nil timer_id = nil, ---@type number|nil attivita_id = nil, ---@type number|nil running = false, -- inactivity_ms: milliseconds of silence before the timer is auto-paused inactivity_ms = 10 * 60 * 1000, -- last epoch (seconds) this instance wrote to the shared activity file last_activity_time = nil, ---@type number|nil uv_timer = nil, ---@type uv.uv_timer_t|nil augroup = nil, ---@type number|nil } -- --------------------------------------------------------------------------- -- Shared-activity timestamp (multi-instance coordination) -- --------------------------------------------------------------------------- --- Path of the shared last-activity file for the current attivita_id. --- @return string|nil local function activity_file_path() if not state.attivita_id then return nil end return "/tmp/jasper_" .. tostring(state.attivita_id) .. ".last_activity" end --- Write the current epoch (seconds) to the shared activity file. local function touch_activity_file() local path = activity_file_path() if not path then return end local now = os.time() local f = io.open(path, "w") if f then f:write(tostring(now)) f:close() state.last_activity_time = now end end --- Write 0 to the shared file to signal that the timer has been paused. --- Other instances will detect this in on_activity() and resume accordingly. local function mark_timer_paused() local path = activity_file_path() if not path then return end local f = io.open(path, "w") if f then f:write("0") f:close() -- Do NOT update last_activity_time: cleanup logic must keep working correctly end end --- Return true if another instance has recorded activity within `inactivity_ms`. local function other_instance_active() local path = activity_file_path() if not path then return false end local f = io.open(path, "r") if not f then return false end local raw = f:read("*l") f:close() local ts = tonumber(raw) if not ts then return false end local elapsed_ms = (os.time() - ts) * 1000 return elapsed_ms < state.inactivity_ms end --- Remove the shared activity file on exit, but only if this instance was --- the last to write it. If another instance wrote a newer timestamp, leave --- the file intact so the other instance's watchdog keeps working correctly. local function cleanup_activity_file() local path = activity_file_path() if not path or not state.last_activity_time then return end local f = io.open(path, "r") if not f then return end local raw = f:read("*l") f:close() local ts = tonumber(raw) -- Only remove if the timestamp on disk matches what we wrote last if ts and ts == state.last_activity_time then os.remove(path) end end -- --------------------------------------------------------------------------- -- Helpers -- --------------------------------------------------------------------------- local function notify(msg, level) vim.notify("[Jasper] " .. msg, level or vim.log.levels.INFO) end --- Stop the uv inactivity countdown (without pausing the Jasper timer). local function cancel_inactivity_watchdog() if state.uv_timer then state.uv_timer:stop() end end --- (Re)start the inactivity countdown. --- When it fires the Jasper timer is paused. local function arm_inactivity_watchdog() if not state.uv_timer or not state.running then return end state.uv_timer:stop() state.uv_timer:start( state.inactivity_ms, 0, vim.schedule_wrap(function() if not state.running then return end -- Don't pause if another instance with the same attivita_id is still active if other_instance_active() then notify("Inactivity detected, but another instance is active — keeping timer running") arm_inactivity_watchdog() return end local ok, err = api.stop_timer(state.token, state.timer_id) if ok then state.running = false mark_timer_paused() -- signal to other instances that the timer is now paused notify("Timer paused (inactivity)") else notify("Could not pause timer: " .. (err or ""), vim.log.levels.WARN) end end) ) end --- Find the smallest non-negative integer not already used as a timer_id. --- @param timers table[] --- @return number local function next_free_timer_id(timers) local used = {} for _, t in ipairs(timers) do used[t.timer_id] = true end local id = 0 while used[id] do id = id + 1 end return id end --- Find an existing timer for `attivita_id` or create a new one. --- @param token string --- @param attivita_id number --- @param timers table[] --- @return number|nil timer_id --- @return string|nil error local function find_or_create_timer(token, attivita_id, timers) for _, t in ipairs(timers) do if t["attivita__id"] == attivita_id then return t.timer_id, nil end end -- No existing timer: create one in the smallest free slot local new_id = next_free_timer_id(timers) local ok, err = api.create_timer(token, new_id, attivita_id) if not ok then return nil, "Cannot create timer: " .. (err or "unknown error") end notify("New timer created (ID " .. new_id .. ")") return new_id, nil end -- --------------------------------------------------------------------------- -- Activity callback (called from autocmds) -- --------------------------------------------------------------------------- function M.on_activity() if not state.timer_id then return end -- Sync local running state with the shared file. -- If another instance paused the timer it writes "0" as a sentinel. -- Detect it here so we can resume without waiting for the other instance. if state.running then local path = activity_file_path() if path then local f = io.open(path, "r") if f then local raw = f:read("*l") f:close() if tonumber(raw) == 0 then state.running = false -- remote pause detected; will resume below end end end end touch_activity_file() if not state.running then -- Resume after an inactivity pause local ok, err = api.start_timer(state.token, state.timer_id) if ok then state.running = true notify("Timer resumed") else notify("Could not resume timer: " .. (err or ""), vim.log.levels.WARN) return end end arm_inactivity_watchdog() end -- --------------------------------------------------------------------------- -- Lifecycle -- --------------------------------------------------------------------------- --- Start tracking: called after token + timer_id are known. --- @param token string --- @param timer_id number local function begin_tracking(token, timer_id) state.token = token state.timer_id = timer_id -- Close any leftover uv_timer from a previous (unexpected) call if state.uv_timer then state.uv_timer:stop() state.uv_timer:close() state.uv_timer = nil end -- Create the libuv timer used for inactivity detection state.uv_timer = vim.uv.new_timer() -- Register activity autocmds state.augroup = vim.api.nvim_create_augroup("JasperActivity", { clear = true }) vim.api.nvim_create_autocmd( { "CursorMoved", "CursorMovedI", "InsertCharPre", "BufEnter", "FocusGained" }, { group = state.augroup, callback = M.on_activity } ) -- Start the timer immediately local ok, err = api.start_timer(token, timer_id) if not ok then notify("Could not start timer: " .. (err or ""), vim.log.levels.ERROR) return end state.running = true notify("Timer started (ID " .. timer_id .. ")") arm_inactivity_watchdog() end --- Called after a valid token is obtained (either from cache or fresh login). --- Fetches timers, creates one if needed, then begins tracking. --- @param token string local function on_token_ready(token) local timers, err = api.get_timers(token) if err == "unauthorized" then -- Token is expired: delete it and ask the user to login again auth.delete_token() notify("Session expired. Please re-open Neovim to login again.", vim.log.levels.WARN) return end if err then notify("Cannot fetch timers: " .. err, vim.log.levels.ERROR) return end if not timers then notify("Cannot fetch timers: empty response", vim.log.levels.ERROR) return end local timer_id, terr = find_or_create_timer(token, state.attivita_id, timers) if not timer_id then notify(terr or "Cannot get timer", vim.log.levels.ERROR) return end begin_tracking(token, timer_id) end --- Entry point called on VimEnter. --- @param opts table { attivita_id, inactivity_timeout } function M.setup(opts) state.attivita_id = opts.attivita_id state.inactivity_ms = (opts.inactivity_timeout or 10) * 60 * 1000 local token = auth.get_token() if token then on_token_ready(token) else auth.prompt_login(function(new_token) if new_token then on_token_ready(new_token) end end) end end --- Stop tracking: called on VimLeave. function M.teardown() cancel_inactivity_watchdog() if state.uv_timer then state.uv_timer:close() state.uv_timer = nil end cleanup_activity_file() if state.running and state.timer_id then local ok, err = api.stop_timer(state.token, state.timer_id) if ok then state.running = false else -- Best-effort; we're exiting anyway vim.notify("[Jasper] Could not stop timer on exit: " .. (err or ""), vim.log.levels.WARN) end end end return M