Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions src/main/git/runner-wsl-gh-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,9 +643,10 @@ describe('ghExecFileAsync WSL fallback', () => {
callback(new Error('Wrong distro fallback'))
})

await expect(
ghExecFileAsync(['auth', 'status'])
).resolves.toEqual({ stdout: 'Logged in to github.com as override', stderr: '' })
await expect(ghExecFileAsync(['auth', 'status'])).resolves.toEqual({
stdout: 'Logged in to github.com as override',
stderr: ''
})

expect(execFileMock).toHaveBeenCalledTimes(2)
expect(execFileMock).toHaveBeenNthCalledWith(
Expand All @@ -672,9 +673,10 @@ describe('ghExecFileAsync WSL fallback', () => {
callback(new Error('Wrong distro fallback'))
})

await expect(
ghExecFileAsync(['auth', 'status'])
).resolves.toEqual({ stdout: 'Logged in to github.com as default', stderr: '' })
await expect(ghExecFileAsync(['auth', 'status'])).resolves.toEqual({
stdout: 'Logged in to github.com as default',
stderr: ''
})

expect(execFileMock).toHaveBeenCalledTimes(2)
expect(execFileMock).toHaveBeenNthCalledWith(
Expand Down
16 changes: 12 additions & 4 deletions src/main/persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3652,7 +3652,9 @@ describe('Store', () => {
store.removeProjectForHost('shared', 'ssh:ssh-a')

// The removed SSH host's session is pruned; the surviving local session stays.
expect(store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo']).toBeUndefined()
expect(
store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo']
).toBeUndefined()
expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.['shared::/repo']).toBe(111)
})

Expand Down Expand Up @@ -3684,7 +3686,9 @@ describe('Store', () => {
store.removeProjectForHost('shared', 'local')

expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.['shared::/repo']).toBeUndefined()
expect(store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo']).toBe(222)
expect(
store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo']
).toBe(222)
})

it('removeProjectForHost prunes only the removed host when a third host also shares the owner key', async () => {
Expand Down Expand Up @@ -3734,9 +3738,13 @@ describe('Store', () => {
store.removeProjectForHost('shared', 'ssh:ssh-a')

// Only the removed host's partition is pruned; local and the other SSH host survive.
expect(store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo']).toBeUndefined()
expect(
store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo']
).toBeUndefined()
expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.['shared::/repo']).toBe(111)
expect(store.getWorkspaceSession('ssh:ssh-b').lastVisitedAtByWorktreeId?.['shared::/repo']).toBe(333)
expect(
store.getWorkspaceSession('ssh:ssh-b').lastVisitedAtByWorktreeId?.['shared::/repo']
).toBe(333)
})

it('reorderReposForHost independently reorders local and SSH rows with shared ids', async () => {
Expand Down
4 changes: 3 additions & 1 deletion src/main/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2499,7 +2499,9 @@ function removeWorkspaceSessionOwners(
for (const ownerKey of ownerKeys) {
deleteOwnerKeyedSessionFields(next, ownerKey, removedTabIds)
}
deleteScannedSessionFieldsForOwners(next, removedTabIds, (worktreeId) => ownerKeys.has(worktreeId))
deleteScannedSessionFieldsForOwners(next, removedTabIds, (worktreeId) =>
ownerKeys.has(worktreeId)
)
return next
}

Expand Down
65 changes: 57 additions & 8 deletions src/main/rate-limits/claude-pty-reset-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ const MONTH_DAY_TIME_RE = new RegExp(
const WEEKDAY_TIME_RE =
/\b(sun(?:day)?|mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?)\.?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i
const TIME_ONLY_RE = /\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i
// Why: newer Codex CLIs print 24-hour reset times ("10:21 on 28 Jul") with no am/pm.
const TIME_24H_RE = /\b(\d{1,2}):(\d{2})\b/
const DAY_MONTH_RE = new RegExp(`\\b(?:on\\s+)?(\\d{1,2})\\s+(${MONTH_PATTERN})\\b`, 'i')
const RELATIVE_RESET_RE = /^(?:\s*\d+\s*(?:d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?)\s*)+$/i
const RELATIVE_RESET_TOKEN_RE = /(\d+)\s*(d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?)/gi
const IANA_TIME_ZONE_RE = /\(([^()]*)\)?\s*$/
Expand Down Expand Up @@ -94,13 +97,16 @@ export function extractClaudePtyResetMetadata(
function normalizeResetDescription(raw: string): string {
// Why: Claude's TUI occasionally drops spaces around the Fable reset date
// when copied from the PTY buffer, but the value still encodes a real reset.
return raw
.trim()
.replace(/[)]+$/, '')
.replace(/\s+/g, ' ')
.replace(MONTH_DAY_COMPACT_RE, '$1 $2')
.replace(/(\d{1,2})\s*at\s*(\d{1,2}(?::\d{2})?\s*(?:am|pm))/i, '$1 at $2')
.replace(/(\d)(am|pm)\(/gi, '$1$2 (')
return (
raw
.trim()
// Why: PTY captures keep trailing box-border glyphs from framed status panels.
.replace(/[)\s│]+$/, '')
.replace(/\s+/g, ' ')
.replace(MONTH_DAY_COMPACT_RE, '$1 $2')
.replace(/(\d{1,2})\s*at\s*(\d{1,2}(?::\d{2})?\s*(?:am|pm))/i, '$1 at $2')
.replace(/(\d)(am|pm)\(/gi, '$1$2 (')
)
}

function parseResetTimestamp(resetDescription: string | null): number | null {
Expand All @@ -112,10 +118,53 @@ function parseResetTimestamp(resetDescription: string | null): number | null {
parseRelativeResetTimestamp(resetDescription) ??
parseMonthDayResetTimestamp(resetDescription) ??
parseWeekdayResetTimestamp(resetDescription) ??
parseTimeOnlyResetTimestamp(resetDescription)
parseTimeOnlyResetTimestamp(resetDescription) ??
parseTwentyFourHourResetTimestamp(resetDescription)
)
}

function parseTwentyFourHourResetTimestamp(resetDescription: string): number | null {
const resetText = stripResetTimeZone(resetDescription)
const timeMatch = TIME_24H_RE.exec(resetText)
if (!timeMatch) {
return null
}
const hour = Number(timeMatch[1])
const minute = Number(timeMatch[2])
if (!isValidClockTime(hour, minute)) {
return null
}

const dayMonthMatch = DAY_MONTH_RE.exec(resetText)
if (dayMonthMatch) {
const day = Number(dayMonthMatch[1])
const monthIndex = MONTH_INDEX_BY_NAME[dayMonthMatch[2].toLowerCase()]
if (monthIndex === undefined || day < 1 || day > 31) {
return null
}
const now = new Date()
const timeZone = extractResetTimeZone(resetDescription)
let timestamp = buildWallClockTimestamp(
{ year: now.getFullYear(), monthIndex, day, hour, minute },
timeZone
)
if (timestamp !== null && timestamp <= Date.now()) {
timestamp = buildWallClockTimestamp(
{ year: now.getFullYear() + 1, monthIndex, day, hour, minute },
timeZone
)
}
return timestamp
}

const candidate = new Date()
candidate.setHours(hour, minute, 0, 0)
if (candidate.getTime() <= Date.now()) {
candidate.setDate(candidate.getDate() + 1)
}
return candidate.getTime()
}

function parseRelativeResetTimestamp(resetDescription: string): number | null {
if (!RELATIVE_RESET_RE.test(resetDescription)) {
return null
Expand Down
Loading
Loading