@link-assistant/hive-mind 1.74.7 → 1.74.9
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/CHANGELOG.md +17 -0
- package/package.json +1 -1
- package/src/codex.lib.mjs +11 -2
- package/src/session-monitor.lib.mjs +72 -1
- package/src/telegram-bot.mjs +2 -2
- package/src/telegram-start-stop-command.lib.mjs +70 -5
- package/src/usage-limit.lib.mjs +35 -17
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.74.9
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c4070e1: Fix incorrect usage-limit reset time for `--tool codex`. Codex reports weekly limits as a full calendar date (e.g. "try again at Jun 11th, 2026 12:27 AM"), but the reset-time parser dropped the month/day/year and kept only the time, making a multi-day weekly reset look like a same-day 5-hour reset. This both mis-informed users and made auto-resume fire far too early. `extractResetTime` now parses ordinal days and explicit years (keyword-independent), `parseResetTime` honors an explicit year, and Codex now traces the raw limit message and parsed reset under verbose mode.
|
|
8
|
+
</content>
|
|
9
|
+
|
|
10
|
+
## 1.74.8
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- c132ce0: Fix `/stop <issue-or-pr-url>` so it can stop tasks that started immediately
|
|
15
|
+
(empty queue) or were already dispatched to a detached isolation session. The
|
|
16
|
+
URL lookup now also consults the session-monitor registry and forwards CTRL+C
|
|
17
|
+
to the tracked start-command UUID, so all three stop modes (issue URL, PR URL,
|
|
18
|
+
and session UUID) work end-to-end (#1871).
|
|
19
|
+
|
|
3
20
|
## 1.74.7
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
package/package.json
CHANGED
package/src/codex.lib.mjs
CHANGED
|
@@ -1009,9 +1009,15 @@ export const executeCodexCommand = async params => {
|
|
|
1009
1009
|
await log(`⚠️ Ignoring non-fatal Codex item error event(s): ${ignoredMessages}`, { level: 'warning', verbose: true });
|
|
1010
1010
|
}
|
|
1011
1011
|
if (codexErrorSummary.hasError) {
|
|
1012
|
-
const
|
|
1013
|
-
const
|
|
1012
|
+
const limitSource = codexErrorSummary.message || lastMessage;
|
|
1013
|
+
const limitInfo = detectUsageLimit(limitSource);
|
|
1014
|
+
const retryableError = classifyRetryableError(limitSource);
|
|
1014
1015
|
if (limitInfo.isUsageLimit) {
|
|
1016
|
+
// Issue #1869: Trace the raw limit text and what we parsed out of it so
|
|
1017
|
+
// a mis-parsed reset (e.g. a weekly reset read as a 5-hour reset) can be
|
|
1018
|
+
// diagnosed from the log without guessing at the original message.
|
|
1019
|
+
await log(`🔍 Codex usage limit detected. Raw message: ${JSON.stringify(limitSource)}`, { verbose: true });
|
|
1020
|
+
await log(`🔍 Parsed reset time: ${JSON.stringify(limitInfo.resetTime)}, timezone: ${JSON.stringify(limitInfo.timezone)}`, { verbose: true });
|
|
1015
1021
|
limitReached = true;
|
|
1016
1022
|
limitResetTime = limitInfo.resetTime;
|
|
1017
1023
|
|
|
@@ -1098,6 +1104,9 @@ export const executeCodexCommand = async params => {
|
|
|
1098
1104
|
// Check for usage limit errors first (more specific)
|
|
1099
1105
|
const limitInfo = detectUsageLimit(lastMessage);
|
|
1100
1106
|
if (limitInfo.isUsageLimit) {
|
|
1107
|
+
// Issue #1869: Trace raw limit text + parsed reset for diagnosability.
|
|
1108
|
+
await log(`🔍 Codex usage limit detected (exit ${exitCode}). Raw message: ${JSON.stringify(lastMessage)}`, { verbose: true });
|
|
1109
|
+
await log(`🔍 Parsed reset time: ${JSON.stringify(limitInfo.resetTime)}, timezone: ${JSON.stringify(limitInfo.timezone)}`, { verbose: true });
|
|
1101
1110
|
limitReached = true;
|
|
1102
1111
|
limitResetTime = limitInfo.resetTime;
|
|
1103
1112
|
|
|
@@ -150,7 +150,11 @@ function isMessageAlreadyUpdatedError(error) {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
function normalizeSessionUrl(url) {
|
|
153
|
-
|
|
153
|
+
// Strip the fragment first, then any trailing slashes, so URLs that carry a
|
|
154
|
+
// fragment after a trailing slash (e.g. `.../issues/18/#comment`) normalize to
|
|
155
|
+
// the same value as the bare `.../issues/18`. Doing it in the other order
|
|
156
|
+
// would leave a dangling trailing slash. (Issue #1871.)
|
|
157
|
+
return url.replace(/#.*$/, '').replace(/\/+$/, '').toLowerCase();
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
function isNonIsolationSessionActive(sessionName, sessionInfo, verbose = false) {
|
|
@@ -488,6 +492,73 @@ export function hasActiveSessionForUrl(url, verbose = false) {
|
|
|
488
492
|
return { isActive: false, sessionName: null };
|
|
489
493
|
}
|
|
490
494
|
|
|
495
|
+
const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Issue #1871: Find a tracked, still-running session for a GitHub issue/PR URL
|
|
499
|
+
* and report whether it can be stopped by forwarding CTRL+C to the
|
|
500
|
+
* start-command session UUID.
|
|
501
|
+
*
|
|
502
|
+
* The `/stop <url>` Telegram flow originally consulted only the in-memory solve
|
|
503
|
+
* queue. But a `/solve` or `/codex` that starts immediately (queue empty)
|
|
504
|
+
* dispatches straight to a detached isolation session and is removed from the
|
|
505
|
+
* queue's `processing` Map the moment it is launched. From that point on the
|
|
506
|
+
* session-monitor's in-memory registry is the only place that still knows the
|
|
507
|
+
* URL → start-command-UUID mapping, so `/stop <url>` reported "no task found"
|
|
508
|
+
* even though the task was clearly running. This helper exposes that registry
|
|
509
|
+
* so the stop flow can recover the UUID and interrupt the session.
|
|
510
|
+
*
|
|
511
|
+
* A session is stoppable when it was launched with an isolation backend and its
|
|
512
|
+
* start-command UUID is UUID-shaped (the value `$ --stop <uuid>` expects). Plain
|
|
513
|
+
* non-isolation screen sessions are reported but marked `stoppable: false`
|
|
514
|
+
* because `$ --stop` cannot interrupt them.
|
|
515
|
+
*
|
|
516
|
+
* @param {string} url - GitHub issue or PR URL (any normalization)
|
|
517
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
518
|
+
* @returns {{ sessionName: string, sessionId: string|null, sessionInfo: Object,
|
|
519
|
+
* isolationBackend: string|null, stoppable: boolean }|null} Match or null
|
|
520
|
+
*/
|
|
521
|
+
export function findStoppableSessionByUrl(url, verbose = false) {
|
|
522
|
+
if (!url) return null;
|
|
523
|
+
|
|
524
|
+
const normalizedUrl = normalizeSessionUrl(url);
|
|
525
|
+
|
|
526
|
+
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
527
|
+
if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
// Issue #1586: skip expired non-isolation sessions — they are no longer running.
|
|
531
|
+
if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// The UUID `$ --stop` expects is the start-command session id. For
|
|
536
|
+
// isolation sessions it is tracked either as sessionInfo.sessionId or as
|
|
537
|
+
// the (UUID-shaped) session key itself.
|
|
538
|
+
const candidateId = sessionInfo.sessionId || sessionName;
|
|
539
|
+
const sessionId = SESSION_UUID_RE.test(candidateId) ? candidateId : null;
|
|
540
|
+
const stoppable = Boolean(sessionInfo.isolationBackend && sessionId);
|
|
541
|
+
|
|
542
|
+
if (verbose) {
|
|
543
|
+
const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation';
|
|
544
|
+
console.log(`[VERBOSE] findStoppableSessionByUrl: matched ${sessionName} for ${url} (${mode}, stoppable=${stoppable})`);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
sessionName,
|
|
549
|
+
sessionId,
|
|
550
|
+
sessionInfo,
|
|
551
|
+
isolationBackend: sessionInfo.isolationBackend || null,
|
|
552
|
+
stoppable,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (verbose) {
|
|
557
|
+
console.log(`[VERBOSE] findStoppableSessionByUrl: no tracked session for ${url}`);
|
|
558
|
+
}
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
|
|
491
562
|
/**
|
|
492
563
|
* Async active-session check for command handlers.
|
|
493
564
|
*
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -43,7 +43,7 @@ const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized
|
|
|
43
43
|
const { installTelegramFormattingFallback, isTelegramFormattingError, isTelegramMessageTooLongError, safeEditMessageText, safeReply, TELEGRAM_TEXT_LIMIT } = await import('./telegram-safe-reply.lib.mjs');
|
|
44
44
|
const { registerTerminalWatchCommand, startAutoTerminalWatchForSession } = await import('./telegram-terminal-watch-command.lib.mjs');
|
|
45
45
|
const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
|
|
46
|
-
const { trackSession, startSessionMonitoring, hasActiveSessionForUrlAsync } = await import('./session-monitor.lib.mjs');
|
|
46
|
+
const { trackSession, startSessionMonitoring, hasActiveSessionForUrlAsync, findStoppableSessionByUrl } = await import('./session-monitor.lib.mjs');
|
|
47
47
|
const { formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } = await import('./work-session-formatting.lib.mjs');
|
|
48
48
|
const { buildTelegramHelpMessage, buildTelegramInfoBlock, buildSolveQueuedMessage } = await import('./telegram-ui-messages.lib.mjs');
|
|
49
49
|
|
|
@@ -1068,7 +1068,7 @@ const { registerTopCommand } = await import('./telegram-top-command.lib.mjs');
|
|
|
1068
1068
|
const { registerStartStopCommands } = await import('./telegram-start-stop-command.lib.mjs');
|
|
1069
1069
|
const { registerLogCommand } = await import('./telegram-log-command.lib.mjs');
|
|
1070
1070
|
registerTopCommand(bot, sharedCommandOpts);
|
|
1071
|
-
registerStartStopCommands(bot, { ...sharedCommandOpts, getSolveQueue });
|
|
1071
|
+
registerStartStopCommands(bot, { ...sharedCommandOpts, getSolveQueue, findRunningSessionByUrl: (url, verbose) => findStoppableSessionByUrl(url, verbose) });
|
|
1072
1072
|
await registerLogCommand(bot, sharedCommandOpts);
|
|
1073
1073
|
await registerTerminalWatchCommand(bot, sharedCommandOpts);
|
|
1074
1074
|
|
|
@@ -16,10 +16,15 @@
|
|
|
16
16
|
* - `/stop <issue-or-pr-url>` (or reply to a message that contains one) looks
|
|
17
17
|
* the URL up in the in-memory solve queue and either cancels the queued
|
|
18
18
|
* item or forwards CTRL+C to the running isolated session (issue #1780).
|
|
19
|
+
* - `/stop <issue-or-pr-url>` also consults the session-monitor registry of
|
|
20
|
+
* running detached sessions, so it can interrupt a task that started
|
|
21
|
+
* immediately (queue empty) and was therefore never left in the queue's
|
|
22
|
+
* `processing` Map — the case shown in issue #1871's screenshots.
|
|
19
23
|
*
|
|
20
24
|
* @see https://github.com/link-assistant/hive-mind/issues/1081
|
|
21
25
|
* @see https://github.com/link-assistant/hive-mind/issues/524
|
|
22
26
|
* @see https://github.com/link-assistant/hive-mind/issues/1780
|
|
27
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1871
|
|
23
28
|
* @see https://github.com/link-foundation/start/issues/112
|
|
24
29
|
*/
|
|
25
30
|
|
|
@@ -257,6 +262,12 @@ export function isStopTargetRequester({ userId, queueItem = null, sessionInfo =
|
|
|
257
262
|
* When omitted, the URL flow degrades gracefully to a "no queue available"
|
|
258
263
|
* message so unit tests for non-URL paths don't need to construct a queue.
|
|
259
264
|
* See https://github.com/link-assistant/hive-mind/issues/1780.
|
|
265
|
+
* @param {Function} [options.findRunningSessionByUrl] - Override for tests; looks
|
|
266
|
+
* the URL up in the session-monitor registry of running detached sessions so
|
|
267
|
+
* `/stop <url>` can interrupt tasks that started immediately (queue empty) and
|
|
268
|
+
* were therefore never left in the queue's `processing` Map. When omitted, the
|
|
269
|
+
* real `findStoppableSessionByUrl` from session-monitor is lazy-imported.
|
|
270
|
+
* See https://github.com/link-assistant/hive-mind/issues/1871.
|
|
260
271
|
*/
|
|
261
272
|
export function registerStartStopCommands(bot, options) {
|
|
262
273
|
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, getSolveQueue } = options;
|
|
@@ -274,6 +285,26 @@ export function registerStartStopCommands(bot, options) {
|
|
|
274
285
|
return mod.getTrackedSessionInfo(sessionId);
|
|
275
286
|
}
|
|
276
287
|
|
|
288
|
+
// Issue #1871: look a URL up in the session-monitor registry of running
|
|
289
|
+
// detached sessions. A /solve or /codex that started immediately (queue
|
|
290
|
+
// empty) is dispatched straight to an isolation session and removed from the
|
|
291
|
+
// queue's `processing` Map, so the queue lookup alone reports "no task found"
|
|
292
|
+
// for a task that is clearly running. The session monitor still knows the
|
|
293
|
+
// URL → start-command-UUID mapping, which lets /stop <url> recover and
|
|
294
|
+
// interrupt the session. Test stubs can inject findRunningSessionByUrl.
|
|
295
|
+
async function lookupRunningSessionByUrl(url) {
|
|
296
|
+
try {
|
|
297
|
+
if (typeof options.findRunningSessionByUrl === 'function') {
|
|
298
|
+
return await options.findRunningSessionByUrl(url, VERBOSE);
|
|
299
|
+
}
|
|
300
|
+
const mod = await import('./session-monitor.lib.mjs');
|
|
301
|
+
return mod.findStoppableSessionByUrl(url, VERBOSE);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
console.error('[ERROR] /stop: findStoppableSessionByUrl failed:', error);
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
277
308
|
/**
|
|
278
309
|
* Validate command context: checks old message, forwarded, group chat, authorized, and owner status.
|
|
279
310
|
* @param {Object} ctx - Telegraf context
|
|
@@ -529,18 +560,42 @@ export function registerStartStopCommands(bot, options) {
|
|
|
529
560
|
const url = target.value;
|
|
530
561
|
VERBOSE && console.log(`[VERBOSE] /stop: detected URL ${url} (source=${target.source})`);
|
|
531
562
|
|
|
532
|
-
// Look up the queue item
|
|
533
|
-
// requester to cancel their own task in a
|
|
534
|
-
//
|
|
535
|
-
//
|
|
563
|
+
// Look up the queue item AND any running detached session BEFORE auth so
|
|
564
|
+
// we can allow the original task requester to cancel their own task in a
|
|
565
|
+
// group (#1783), regardless of whether the task is still queued or already
|
|
566
|
+
// dispatched to an isolation session (#1871). Neither lookup mutates
|
|
567
|
+
// state — actual cancel/stop happens below after auth has passed.
|
|
536
568
|
const candidate = findQueueCandidateForUrl(url);
|
|
537
|
-
const
|
|
569
|
+
const runningSession = await lookupRunningSessionByUrl(url);
|
|
570
|
+
const ok = await authorizeTargetedStop(ctx, 'URL', { queueItem: candidate.item || null, sessionInfo: runningSession?.sessionInfo || null });
|
|
538
571
|
if (!ok) return;
|
|
539
572
|
|
|
540
573
|
const lookup = resolveQueueLookupForUrl(url);
|
|
541
574
|
VERBOSE && console.log(`[VERBOSE] /stop: queue lookup for ${url} → ${lookup.action}`);
|
|
542
575
|
|
|
576
|
+
// Issue #1871: when the queue has no record of the task (it started
|
|
577
|
+
// immediately and was dispatched to a detached session) but the session
|
|
578
|
+
// monitor still tracks a running isolated session for this URL, forward
|
|
579
|
+
// CTRL+C to its start-command UUID. This is the common case for tasks
|
|
580
|
+
// that begin executing right away with `--isolation screen`.
|
|
581
|
+
const queueHasTask = lookup.action === 'cancel-queued' || lookup.action === 'stop-running';
|
|
582
|
+
if (!queueHasTask && runningSession?.stoppable && runningSession.sessionId) {
|
|
583
|
+
VERBOSE && console.log(`[VERBOSE] /stop: forwarding CTRL+C to tracked session ${runningSession.sessionId} for ${url} (queue action=${lookup.action})`);
|
|
584
|
+
await runStopIsolatedSessionFlow(ctx, runningSession.sessionId);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
543
588
|
if (lookup.action === 'no-queue') {
|
|
589
|
+
// No solve queue in this context. If the session monitor found a
|
|
590
|
+
// running-but-non-stoppable (non-isolation) session, say so; otherwise
|
|
591
|
+
// fall back to the UUID hint.
|
|
592
|
+
if (runningSession) {
|
|
593
|
+
await ctx.reply(`⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time you can run the command with \`--isolation screen\` to make this task interruptible via \`/stop\`.`, {
|
|
594
|
+
parse_mode: 'Markdown',
|
|
595
|
+
reply_to_message_id: message.message_id,
|
|
596
|
+
});
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
544
599
|
await ctx.reply(`ℹ️ Cannot look up tasks by URL right now (the bot has no solve queue available in this context).\n\nIf you have the session UUID, you can use \`/stop <UUID>\` instead.`, {
|
|
545
600
|
parse_mode: 'Markdown',
|
|
546
601
|
reply_to_message_id: message.message_id,
|
|
@@ -549,6 +604,16 @@ export function registerStartStopCommands(bot, options) {
|
|
|
549
604
|
}
|
|
550
605
|
|
|
551
606
|
if (lookup.action === 'not-found') {
|
|
607
|
+
// The session monitor also had no stoppable session (otherwise we would
|
|
608
|
+
// have forwarded CTRL+C above). If it tracked a non-isolation session,
|
|
609
|
+
// explain why it can't be stopped; otherwise report not found.
|
|
610
|
+
if (runningSession) {
|
|
611
|
+
await ctx.reply(`⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time you can run the command with \`--isolation screen\` to make this task interruptible via \`/stop\`.`, {
|
|
612
|
+
parse_mode: 'Markdown',
|
|
613
|
+
reply_to_message_id: message.message_id,
|
|
614
|
+
});
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
552
617
|
await ctx.reply(`ℹ️ No queued or running task found for ${url}.\n\nIf the task is running with \`--isolation screen\`, try \`/stop <UUID>\` (the UUID is shown in the bot's session-id message).`, {
|
|
553
618
|
parse_mode: 'Markdown',
|
|
554
619
|
reply_to_message_id: message.message_id,
|
package/src/usage-limit.lib.mjs
CHANGED
|
@@ -121,19 +121,33 @@ export function extractResetTime(message) {
|
|
|
121
121
|
// Normalize whitespace for easier matching
|
|
122
122
|
const normalized = message.replace(/\s+/g, ' ');
|
|
123
123
|
|
|
124
|
-
// Pattern 0: Weekly limit with
|
|
125
|
-
// This pattern must come
|
|
124
|
+
// Pattern 0: Weekly/long-window limit with an explicit calendar date.
|
|
125
|
+
// This pattern must come FIRST so a date+time is never truncated to a bare
|
|
126
|
+
// time by the time-only patterns below (Issue #1869): Codex reports weekly
|
|
127
|
+
// limits as "try again at Jun 11th, 2026 12:27 AM" — keeping only "12:27 AM"
|
|
128
|
+
// makes a 2-days-out weekly reset look like a same-day 5-hour reset, which
|
|
129
|
+
// both mis-informs the user and triggers a far-too-early auto-resume.
|
|
130
|
+
//
|
|
131
|
+
// Handled shapes (keyword prefix like "resets"/"try again at" is optional —
|
|
132
|
+
// the month+day+time structure is specific enough on its own):
|
|
133
|
+
// - "resets Jan 15, 8am" (no year, Claude weekly)
|
|
134
|
+
// - "resets January 20, 10:30am"
|
|
135
|
+
// - "try again at Jun 11th, 2026 12:27 AM" (ordinal day + year, Codex weekly)
|
|
126
136
|
const monthPattern = '(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)';
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
+
// (month) (day)[ordinal] [, year] [,] (hour)[:minute] (am|pm)
|
|
138
|
+
const dateWithTimeRegex = new RegExp(`(${monthPattern})\\s+(\\d{1,2})(?:st|nd|rd|th)?(?:,?\\s+(\\d{4}))?,?\\s+([0-9]{1,2})(?::([0-9]{2}))?\\s*([ap]m)`, 'i');
|
|
139
|
+
const dateWithTime = normalized.match(dateWithTimeRegex);
|
|
140
|
+
if (dateWithTime) {
|
|
141
|
+
const month = dateWithTime[1];
|
|
142
|
+
const day = dateWithTime[2];
|
|
143
|
+
const year = dateWithTime[3]; // optional
|
|
144
|
+
const hour = dateWithTime[4];
|
|
145
|
+
const minute = dateWithTime[5] || '00';
|
|
146
|
+
const ampm = dateWithTime[6].toUpperCase();
|
|
147
|
+
// Return formatted date+time string for weekly limits, preserving the year
|
|
148
|
+
// when present so the reset is anchored to the correct day rather than
|
|
149
|
+
// being assumed to be "this year / next occurrence".
|
|
150
|
+
return year ? `${month} ${day}, ${year}, ${hour}:${minute} ${ampm}` : `${month} ${day}, ${hour}:${minute} ${ampm}`;
|
|
137
151
|
}
|
|
138
152
|
|
|
139
153
|
// Pattern 1: "try again at 12:16 PM"
|
|
@@ -244,8 +258,10 @@ export function parseResetTime(timeStr, tz = null) {
|
|
|
244
258
|
const normalized = timeStr.replace(/\bSept\b/gi, 'Sep');
|
|
245
259
|
|
|
246
260
|
// Try date+time formats using dayjs custom parse
|
|
247
|
-
// dayjs uses: MMM=Jan, MMMM=January, D=day, h=12-hour, mm=minutes, A=AM/PM
|
|
248
|
-
|
|
261
|
+
// dayjs uses: MMM=Jan, MMMM=January, D=day, YYYY=year, h=12-hour, mm=minutes, A=AM/PM
|
|
262
|
+
// Year-bearing formats are tried first so an explicit year (Codex weekly
|
|
263
|
+
// limits, Issue #1869) is honored instead of being dropped to the current year.
|
|
264
|
+
const dateTimeFormats = ['MMM D, YYYY, h:mm A', 'MMMM D, YYYY, h:mm A', 'MMM D, h:mm A', 'MMMM D, h:mm A'];
|
|
249
265
|
|
|
250
266
|
for (const format of dateTimeFormats) {
|
|
251
267
|
let parsed;
|
|
@@ -261,9 +277,11 @@ export function parseResetTime(timeStr, tz = null) {
|
|
|
261
277
|
}
|
|
262
278
|
|
|
263
279
|
if (parsed.isValid()) {
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
|
|
280
|
+
// When the format omits the year, dayjs defaults to the current year, so a
|
|
281
|
+
// past date should roll forward to next year. When an explicit year is
|
|
282
|
+
// present (Issue #1869), trust it verbatim and never bump it.
|
|
283
|
+
const hasExplicitYear = format.includes('YYYY');
|
|
284
|
+
if (!hasExplicitYear && parsed.isBefore(now)) {
|
|
267
285
|
parsed = parsed.add(1, 'year');
|
|
268
286
|
}
|
|
269
287
|
return parsed;
|