unsnooze 1.21.0 → 1.21.1
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/multiplexers/herdr.js +42 -9
- package/src/resumer.js +156 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.21.1 — 2026-10-26
|
|
4
|
+
|
|
5
|
+
A herdr session that is still open is now resumed in its own pane instead of a
|
|
6
|
+
new tab.
|
|
7
|
+
|
|
8
|
+
### herdr
|
|
9
|
+
|
|
10
|
+
- When a stopped session has no pane on record (it was detected from its
|
|
11
|
+
transcript), or its recorded pane is gone, unsnooze now finds the herdr pane
|
|
12
|
+
already holding that conversation by its session id and resumes there,
|
|
13
|
+
instead of opening a new tab that duplicated the conversation. If herdr
|
|
14
|
+
reports the agent there as working, unsnooze waits (#47, #48).
|
|
15
|
+
- unsnooze never guesses. If two panes claim the session, or the pane holding
|
|
16
|
+
it isn't safe to type into, the stop is marked failed with the reason and
|
|
17
|
+
one notification. If another unsnooze owns the pane, or herdr can't be
|
|
18
|
+
read, it counts as a failed attempt, so the stop still ends normally.
|
|
19
|
+
|
|
3
20
|
## 1.21.0 — 2026-10-24
|
|
4
21
|
|
|
5
22
|
Codex sub-agents no longer show up as a second, unresumable session, a thread
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unsnooze",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.1",
|
|
4
4
|
"description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, Antigravity, and Cursor CLI sessions when 5-hour or weekly usage limits reset—on macOS, Linux and Windows, with or without a terminal multiplexer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -524,15 +524,17 @@ export function createHerdr({ spawner = defaultSpawner, env = process.env } = {}
|
|
|
524
524
|
},
|
|
525
525
|
|
|
526
526
|
async listSessions() {
|
|
527
|
-
try {
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
527
|
+
try { return await backend.readSessions(); } catch { return []; }
|
|
528
|
+
},
|
|
529
|
+
|
|
530
|
+
// listSessions, but a failed `session list` throws instead of reading as
|
|
531
|
+
// "no sessions".
|
|
532
|
+
async readSessions() {
|
|
533
|
+
const result = parseResult(await run(['session', 'list', '--json']));
|
|
534
|
+
const rows = Array.isArray(result?.sessions) ? result.sessions : [];
|
|
535
|
+
return rows
|
|
536
|
+
.filter(row => typeof row?.name === 'string' && row.name)
|
|
537
|
+
.map(row => ({ name: row.name, exited: row.running === false }));
|
|
536
538
|
},
|
|
537
539
|
|
|
538
540
|
async listSessionPanes(sessionName) {
|
|
@@ -544,6 +546,37 @@ export function createHerdr({ spawner = defaultSpawner, env = process.env } = {}
|
|
|
544
546
|
}
|
|
545
547
|
},
|
|
546
548
|
|
|
549
|
+
// Every live pane, in every running session, whose agent reports this
|
|
550
|
+
// conversation id. herdr tracks `agent_session` per pane, so this finds a
|
|
551
|
+
// session that is still open somewhere even when unsnooze never recorded
|
|
552
|
+
// its pane (transcript-only detection) or recorded a stale id. The cwd is
|
|
553
|
+
// deliberately not consulted: several conversations share a repository.
|
|
554
|
+
async findSessionPanes(sessionId, { agent = null } = {}) {
|
|
555
|
+
if (!sessionId) return [];
|
|
556
|
+
const found = [];
|
|
557
|
+
let failure = null;
|
|
558
|
+
for (const row of await backend.readSessions()) {
|
|
559
|
+
if (row.exited) continue;
|
|
560
|
+
let panes;
|
|
561
|
+
try { panes = await listPaneRows(row.name); } catch (err) { failure ??= err; continue; }
|
|
562
|
+
for (const entry of panes) {
|
|
563
|
+
const identity = entry?.agent_session;
|
|
564
|
+
if (!identity || identity.value !== sessionId || !entry.pane_id) continue;
|
|
565
|
+
if (agent && identity.agent !== agent) continue;
|
|
566
|
+
found.push({
|
|
567
|
+
pane: String(entry.pane_id),
|
|
568
|
+
paneOwner: row.name,
|
|
569
|
+
status: entry.agent_status ?? null,
|
|
570
|
+
stamp: entry.tokens?.unsnooze_owner ?? null,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// An unreadable session may hold the conversation (or a second pane
|
|
575
|
+
// for it): a partial answer must not read as "no other pane".
|
|
576
|
+
if (failure) throw failure;
|
|
577
|
+
return found;
|
|
578
|
+
},
|
|
579
|
+
|
|
547
580
|
async closePane(pane) {
|
|
548
581
|
await owned('pane', 'close', String(pane));
|
|
549
582
|
},
|
package/src/resumer.js
CHANGED
|
@@ -598,6 +598,28 @@ export async function planFor(rec, {
|
|
|
598
598
|
+ ' knows its session id — reviving both would clone the conversation');
|
|
599
599
|
return { ...base, action: 'superseded', target: { key: superseded.key } };
|
|
600
600
|
}
|
|
601
|
+
const { matches, failed } = await lookupExistingPane(rec, agent, mux);
|
|
602
|
+
if (failed) {
|
|
603
|
+
gates.push('existing-pane lookup failed — dispatch counts it as a failed attempt');
|
|
604
|
+
return { ...base, action: 'retry', target: { pane: rec.pane } };
|
|
605
|
+
}
|
|
606
|
+
if (matches.length > 1) {
|
|
607
|
+
gates.push(`${matches.length} panes claim this session — would mark failed and notify rather than guess`);
|
|
608
|
+
return { ...base, action: 'give-up' };
|
|
609
|
+
}
|
|
610
|
+
if (matches.length === 1) {
|
|
611
|
+
const [hit] = matches;
|
|
612
|
+
if (foreignStamp(hit, rec)) {
|
|
613
|
+
gates.push(`existing pane ${hit.paneOwner}:${hit.pane} is owned by another unsnooze — left to it, dispatch counts it as a failed attempt`);
|
|
614
|
+
return { ...base, action: 'retry', target: { pane: hit.pane } };
|
|
615
|
+
}
|
|
616
|
+
if (hit.status === 'working') {
|
|
617
|
+
gates.push(`existing pane ${hit.paneOwner}:${hit.pane} holds this session and herdr reports it working — deferring`);
|
|
618
|
+
return { ...base, action: 'busy', target: { pane: hit.pane } };
|
|
619
|
+
}
|
|
620
|
+
gates.push(`found existing pane ${hit.paneOwner}:${hit.pane} by session id — would resume there if idle (no new tab)`);
|
|
621
|
+
return { ...base, action: 'inject', target: { pane: hit.pane, session: hit.paneOwner }, message };
|
|
622
|
+
}
|
|
601
623
|
const target = await reviveTarget(mux, rec);
|
|
602
624
|
// The same argv reopen() builds: on a backend with no pane the prompt rides
|
|
603
625
|
// in argv, and for codex the command itself changes (`exec resume`).
|
|
@@ -609,12 +631,113 @@ export async function planFor(rec, {
|
|
|
609
631
|
};
|
|
610
632
|
}
|
|
611
633
|
|
|
634
|
+
// An existing pane that already holds this conversation, found by agent
|
|
635
|
+
// session id (never by cwd). Only multiplexers that expose findSessionPanes
|
|
636
|
+
// (herdr) can answer; a record with no recorded mux is also tried against herdr
|
|
637
|
+
// when it is installed, since transcript detection never learns the pane —
|
|
638
|
+
// unless its origin is a GUI app, which herdr cannot be hosting.
|
|
639
|
+
const GUI_ORIGIN = /desktop|vscode|jetbrains|ide/i;
|
|
640
|
+
|
|
641
|
+
function existingPaneFinder(rec, mux) {
|
|
642
|
+
if (!rec.sessionId) return null;
|
|
643
|
+
if (typeof mux?.findSessionPanes === 'function') return mux;
|
|
644
|
+
// Transcript records carry the default backend's name but no pane.
|
|
645
|
+
if (rec.mux && rec.mux !== 'herdr' && rec.pane) return null;
|
|
646
|
+
if (rec.origin && GUI_ORIGIN.test(rec.origin)) return null;
|
|
647
|
+
try {
|
|
648
|
+
const herdr = getMultiplexer('herdr');
|
|
649
|
+
return herdr.available() ? herdr : null;
|
|
650
|
+
} catch { return null; }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function lookupExistingPane(rec, agent, mux) {
|
|
654
|
+
const finder = existingPaneFinder(rec, mux);
|
|
655
|
+
if (!finder) return { finder: null, matches: [] };
|
|
656
|
+
try {
|
|
657
|
+
return { finder, matches: await finder.findSessionPanes(rec.sessionId, { agent: agent.id }) };
|
|
658
|
+
} catch (err) {
|
|
659
|
+
log(`${rec.key}: existing-pane lookup failed: ${err.message}`);
|
|
660
|
+
return { finder, matches: [], failed: err };
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// A pane stamped by a different unsnooze owner is tracked (and will be
|
|
665
|
+
// resumed) by that owner: another launcher on this box, or a fleet host.
|
|
666
|
+
function foreignStamp(hit, rec) {
|
|
667
|
+
return Boolean(hit.stamp) && hit.stamp !== rec.leaseId;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function giveUpOnExistingPane(rec, reason, notifier = notify) {
|
|
671
|
+
if (!transitionStopEpisode(rec, 'failed', {
|
|
672
|
+
lastError: reason, verifyRetries: 0, resumeEpisodeAt: null,
|
|
673
|
+
}, { expect: ['stopped'] })) return 'stale';
|
|
674
|
+
log(`${rec.key}: ${reason}`);
|
|
675
|
+
notifier('unsnooze needs you ⚠️', `${rec.cwd}: ${reason}`, { context: ctxOf(rec), priority: 4 });
|
|
676
|
+
return 'skipped';
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Point the stop at the pane that already holds the conversation so the normal
|
|
680
|
+
// live-pane gates (busy, menu, idle prompt, ownership) decide what happens.
|
|
681
|
+
// Adopted only when the pane carries no unsnooze stamp or this record's own;
|
|
682
|
+
// identity is then the exact agent session id plus the agent still running in
|
|
683
|
+
// the pane (a shell is refused downstream). Returns { rec, mux } to resume
|
|
684
|
+
// into, a dispatch outcome when this stop ends here, or null to reopen.
|
|
685
|
+
async function adoptExistingPane(rec, agent, mux, notifier) {
|
|
686
|
+
const { finder, matches, failed } = await lookupExistingPane(rec, agent, mux);
|
|
687
|
+
if (failed) {
|
|
688
|
+
// Charged as an attempt, so a herdr that keeps failing reaches the normal
|
|
689
|
+
// give-up path instead of silently holding the record.
|
|
690
|
+
if (!transitionStopEpisode(rec, 'stopped', {
|
|
691
|
+
lastError: `existing-pane lookup: ${failed.message}`,
|
|
692
|
+
}, { expect: ['stopped'] })) return { outcome: 'stale' };
|
|
693
|
+
return { outcome: 'retry' };
|
|
694
|
+
}
|
|
695
|
+
if (matches.length === 0) return null;
|
|
696
|
+
if (matches.length > 1) {
|
|
697
|
+
const panes = matches.map(m => `${m.paneOwner}:${m.pane}`).join(', ');
|
|
698
|
+
return { outcome: giveUpOnExistingPane(rec,
|
|
699
|
+
`found ${matches.length} herdr panes for this session (${panes}) — not guessing, resume it by hand`, notifier) };
|
|
700
|
+
}
|
|
701
|
+
const [hit] = matches;
|
|
702
|
+
if (foreignStamp(hit, rec)) {
|
|
703
|
+
// Not typed into, and charged as an attempt: if that owner never resumes
|
|
704
|
+
// it, the normal give-up path still ends the stop.
|
|
705
|
+
if (!transitionStopEpisode(rec, 'stopped', {
|
|
706
|
+
lastError: `existing pane ${hit.paneOwner}:${hit.pane} is owned by another unsnooze — left to it`,
|
|
707
|
+
}, { expect: ['stopped'] })) return { outcome: 'stale' };
|
|
708
|
+
return { outcome: 'retry' };
|
|
709
|
+
}
|
|
710
|
+
// herdr's own agent status catches a busy agent the screen scrape can miss.
|
|
711
|
+
if (hit.status === 'working') return { outcome: 'busy' };
|
|
712
|
+
// Already pointed at it and it was not safe to resume: a new tab would clone it.
|
|
713
|
+
if (hit.pane === rec.pane && hit.paneOwner === rec.paneOwner) {
|
|
714
|
+
return { outcome: giveUpOnExistingPane(rec,
|
|
715
|
+
`herdr pane ${hit.paneOwner}:${hit.pane} holds this session but is not safe to type into — resume it there by hand`, notifier) };
|
|
716
|
+
}
|
|
717
|
+
const cutoff = stopEpisodeAt(rec);
|
|
718
|
+
let adopted = null;
|
|
719
|
+
updateState(state => {
|
|
720
|
+
const current = state.sessions[rec.key];
|
|
721
|
+
if (current?.status !== 'stopped' || stopEpisodeAt(current) !== cutoff) return state;
|
|
722
|
+
Object.assign(current, {
|
|
723
|
+
mux: 'herdr', pane: hit.pane, paneOwner: hit.paneOwner, muxSession: hit.paneOwner,
|
|
724
|
+
leaseId: hit.stamp,
|
|
725
|
+
});
|
|
726
|
+
adopted = { ...current };
|
|
727
|
+
return state;
|
|
728
|
+
});
|
|
729
|
+
if (!adopted) return null;
|
|
730
|
+
log(`${rec.key}: found existing herdr pane ${hit.paneOwner}:${hit.pane} by session id — resuming there`);
|
|
731
|
+
return { rec: adopted, mux: finder.bind ? finder.bind(hit.paneOwner) : finder };
|
|
732
|
+
}
|
|
733
|
+
|
|
612
734
|
// Ordered safety decision. Returns: already-resumed | busy | retry | progress |
|
|
613
735
|
// held | injected | reopen | probe.
|
|
614
736
|
export async function dispatchOne(rec, {
|
|
615
737
|
mux = resolveRecordMux(rec), resolveMux = null,
|
|
616
738
|
resumeMessage, selfCmd = selfCommand(), fingerprint = workspaceFingerprint,
|
|
617
739
|
notifier = notify, matchesLease = leaseMatches, contextTokens = null,
|
|
740
|
+
adoptExisting = true, adopted = false,
|
|
618
741
|
} = {}) {
|
|
619
742
|
resolveMux ||= () => mux;
|
|
620
743
|
const key = rec.key;
|
|
@@ -626,6 +749,8 @@ export async function dispatchOne(rec, {
|
|
|
626
749
|
// explicit option → per-agent (`resumeMessages.<id>`) → global. Applies to
|
|
627
750
|
// both the live-pane sendText and the argv reopen path.
|
|
628
751
|
resumeMessage = rec.resumeMessage ?? resumeMessage ?? resolveResumeMessage(agent.id);
|
|
752
|
+
// Guards below suffix the message; an adopted-pane retry re-runs them from the original.
|
|
753
|
+
const baseMessage = resumeMessage;
|
|
629
754
|
|
|
630
755
|
// §4: fallback records probe cheaply before a real resume attempt.
|
|
631
756
|
if (rec.resetSource === 'fallback' && !rec.manual) {
|
|
@@ -677,12 +802,40 @@ export async function dispatchOne(rec, {
|
|
|
677
802
|
const notifyContext = () => {
|
|
678
803
|
if (contextNote) notifier('unsnooze: big-context wake', contextNote, { context: ctxOf(rec) });
|
|
679
804
|
};
|
|
680
|
-
|
|
681
|
-
|
|
805
|
+
let a = null;
|
|
806
|
+
// Before opening a new tab, look for the conversation's own pane. The
|
|
807
|
+
// retry re-enters with adoptExisting off so it cannot loop.
|
|
808
|
+
const reopenGuarded = async () => {
|
|
809
|
+
// An adopted pane that is not safe to type into (a shell, a refused menu)
|
|
810
|
+
// still holds the conversation: a new tab would clone it.
|
|
811
|
+
if (adopted) {
|
|
812
|
+
if (!a?.alive) {
|
|
813
|
+
// Closed between lookup and assessment: retry, the next lookup decides.
|
|
814
|
+
if (!transitionStopEpisode(rec, 'stopped', {
|
|
815
|
+
lastError: `existing pane ${rec.paneOwner}:${rec.pane} closed before resume`,
|
|
816
|
+
}, { expect: ['stopped'] })) return 'stale';
|
|
817
|
+
return 'retry';
|
|
818
|
+
}
|
|
819
|
+
const why = agent.isForegroundCommand(a.cmd) ? 'is not safe to type into' : 'no longer runs the agent';
|
|
820
|
+
return giveUpOnExistingPane(rec,
|
|
821
|
+
`herdr pane ${rec.paneOwner}:${rec.pane} holds this session but ${why} — resume it there by hand`, notifier);
|
|
822
|
+
}
|
|
823
|
+
if (adoptExisting) {
|
|
824
|
+
const found = await adoptExistingPane(rec, agent, mux, notifier);
|
|
825
|
+
if (found?.outcome) return found.outcome;
|
|
826
|
+
if (found) {
|
|
827
|
+
return dispatchOne(found.rec, {
|
|
828
|
+
mux: found.mux, resolveMux, resumeMessage: baseMessage, selfCmd,
|
|
829
|
+
fingerprint, notifier, matchesLease, contextTokens, adoptExisting: false, adopted: true,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd, onDelivered: notifyContext });
|
|
834
|
+
};
|
|
682
835
|
|
|
683
836
|
// Live-pane path: classification comes from the shared assessPane (planFor
|
|
684
837
|
// narrates the identical assessment); dispatch owns every side effect.
|
|
685
|
-
|
|
838
|
+
a = await assessPane(rec, agent, { mux, matchesLease });
|
|
686
839
|
if (a.alive) {
|
|
687
840
|
if (a.captureError) {
|
|
688
841
|
if (!transitionStopEpisode(rec, 'stopped', {
|