unsnooze 1.4.0 → 1.6.0
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/README.md +17 -1
- package/bin/unsnooze.js +5 -0
- package/package.json +1 -1
- package/src/cli.js +21 -3
- package/src/resumer.js +22 -2
- package/src/settings.js +10 -0
- package/src/state.js +6 -0
- package/src/update-check.js +30 -2
- package/src/workspace.js +44 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.6.0 — 2026-07-12
|
|
4
|
+
|
|
5
|
+
- **Stale-workspace guard** (`workspaceGuard`: `off` | `inform` | `pause`,
|
|
6
|
+
default `inform`): the repo's HEAD + dirty state are fingerprinted when a
|
|
7
|
+
session stops and re-checked at wake. `inform` resumes with a "workspace
|
|
8
|
+
changed while you slept — re-read before acting" note in the wake message;
|
|
9
|
+
`pause` holds the session (desktop notification, `workspace changed` marker
|
|
10
|
+
in status) until `unsnooze resume-now`, which prints the diff stat first.
|
|
11
|
+
Non-git directories are unaffected. Suggested by r/codex feedback.
|
|
12
|
+
|
|
13
|
+
## 1.5.0 — 2026-07-12
|
|
14
|
+
|
|
15
|
+
- **`unsnooze update`**: one command to update unsnooze itself — runs
|
|
16
|
+
`npm install -g unsnooze@latest` and immediately prints the new version's
|
|
17
|
+
changelog. Update notices and the daemon toast now say `run: unsnooze
|
|
18
|
+
update` instead of the raw npm command.
|
|
19
|
+
|
|
3
20
|
## 1.4.0 — 2026-07-12
|
|
4
21
|
|
|
5
22
|
- **Update notices**: unsnooze now checks the npm registry (at most once a
|
package/README.md
CHANGED
|
@@ -133,6 +133,7 @@ unsnooze message <id> "text" # per-session wake message (--clear to reset)
|
|
|
133
133
|
unsnooze config list # settings (see below)
|
|
134
134
|
unsnooze config set <k> <v> # e.g. autoResume off
|
|
135
135
|
unsnooze logs [-f] # what unsnooze has been doing
|
|
136
|
+
unsnooze update # update unsnooze itself
|
|
136
137
|
unsnooze daemon # persistent GUI-session watcher (usually run
|
|
137
138
|
# by launchd/systemd via `install --daemon`)
|
|
138
139
|
unsnooze report [agent] # capture a pane to report an undetected banner
|
|
@@ -154,6 +155,7 @@ unsnooze help # full command list (also -h / --help)
|
|
|
154
155
|
| `resumeMessage` | *"Continue where you left off…"* | The message sent to wake a session. Override it for a single session with `unsnooze message <id> "…"` — visible in `unsnooze status`. |
|
|
155
156
|
| `resumeMessages.claude` / `.codex` / `.grok` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
|
|
156
157
|
| `agents.claude` / `agents.codex` / `agents.grok` | `true` / `true` / `false` | Which CLIs are guarded. |
|
|
158
|
+
| `workspaceGuard` | `inform` | Repo changed while a session slept? `inform` wakes it with a heads-up in the message; `pause` holds it (desktop notification, diff shown on `resume-now`); `off` disables. |
|
|
157
159
|
| `updateCheck` | `true` | Daily new-version check (a plain GET to the npm registry, nothing identifying is sent). Notices after commands + one desktop toast per version. |
|
|
158
160
|
|
|
159
161
|
Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
|
|
@@ -177,6 +179,11 @@ all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
|
|
|
177
179
|
block the CLI).
|
|
178
180
|
- **Overload ≠ limit**: 5xx/529/429 transient errors take a seconds-scale
|
|
179
181
|
backoff path ([30,60,120,240,300]s ± jitter) and never enter the ledger.
|
|
182
|
+
- **Stale-workspace guard**: the repo's HEAD + dirty state are fingerprinted
|
|
183
|
+
when a session stops. If another session (or you) changed the repo before
|
|
184
|
+
the wake, the resumed agent is told to re-read before acting — or, with
|
|
185
|
+
`workspaceGuard=pause`, the session is held and `unsnooze resume-now`
|
|
186
|
+
shows the diff first.
|
|
180
187
|
|
|
181
188
|
## Requirements
|
|
182
189
|
|
|
@@ -226,12 +233,21 @@ verifies the limit actually lifted. It replaces the 4am alarm, not the limit.
|
|
|
226
233
|
|
|
227
234
|
### How do I update, and how do I know when to?
|
|
228
235
|
|
|
229
|
-
`npm i -g unsnooze
|
|
236
|
+
`unsnooze update` (or `npm i -g unsnooze`). unsnooze checks the npm registry at most once a day and
|
|
230
237
|
tells you when a newer version exists (a line after CLI commands, plus one
|
|
231
238
|
desktop notification per version); after updating, the next command shows a
|
|
232
239
|
short "what's new" from the changelog. It's a plain registry GET with nothing
|
|
233
240
|
identifying — turn it off with `unsnooze config set updateCheck off`.
|
|
234
241
|
|
|
242
|
+
### What if another session changed the repo while one was stopped?
|
|
243
|
+
|
|
244
|
+
unsnooze fingerprints the workspace (HEAD + uncommitted state) at stop time
|
|
245
|
+
and re-checks at wake. By default the session still resumes, but the wake
|
|
246
|
+
message includes what changed ("HEAD abc1234 → def5678 — re-read before
|
|
247
|
+
continuing"). Set `workspaceGuard` to `pause` to hold such sessions for a
|
|
248
|
+
manual `unsnooze resume-now` (which prints the diff stat), or `off` to
|
|
249
|
+
disable the check.
|
|
250
|
+
|
|
235
251
|
### Does it work if my laptop was asleep or the terminal was closed?
|
|
236
252
|
|
|
237
253
|
Yes — reset times are stored as absolute timestamps and checked every 30
|
package/bin/unsnooze.js
CHANGED
|
@@ -82,6 +82,10 @@ async function main() {
|
|
|
82
82
|
const { runResumer } = await import('../src/resumer.js');
|
|
83
83
|
return runResumer();
|
|
84
84
|
}
|
|
85
|
+
case 'update': {
|
|
86
|
+
const { runSelfUpdate } = await import('../src/update-check.js');
|
|
87
|
+
return runSelfUpdate();
|
|
88
|
+
}
|
|
85
89
|
case '_update-check': {
|
|
86
90
|
const { runUpdateCheck } = await import('../src/update-check.js');
|
|
87
91
|
return runUpdateCheck();
|
|
@@ -116,6 +120,7 @@ Usage:
|
|
|
116
120
|
unsnooze cancel [id|--all] stop tracking session(s)
|
|
117
121
|
unsnooze message <id|--all> <t> set a per-session wake message (--clear to reset)
|
|
118
122
|
unsnooze logs [-f] show (or follow) the unsnooze log
|
|
123
|
+
unsnooze update update unsnooze itself to the latest version
|
|
119
124
|
unsnooze daemon persistent watcher for GUI sessions (VS Code
|
|
120
125
|
extension, desktop apps) — no tmux needed to
|
|
121
126
|
detect; revival still opens in tmux
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unsnooze",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Auto-resume Claude Code, Codex CLI & Grok sessions in tmux when the usage limit (5-hour or weekly) resets. Wakes every limit-stopped AI coding session automatically.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/cli.js
CHANGED
|
@@ -33,8 +33,11 @@ export function cmdStatus() {
|
|
|
33
33
|
const msg = s.resumeMessage
|
|
34
34
|
? ` · msg: "${s.resumeMessage.length > 44 ? s.resumeMessage.slice(0, 44) + '…' : s.resumeMessage}"`
|
|
35
35
|
: '';
|
|
36
|
+
const hold = s.workspaceHold
|
|
37
|
+
? ` · workspace changed (${s.holdReason ?? '?'}) — resume-now to wake`
|
|
38
|
+
: '';
|
|
36
39
|
console.log(` [${s.status.toUpperCase().padEnd(9)}] ${id} ${(s.agent || 'claude').padEnd(6)} ${s.limitType?.padEnd(7) ?? 'unknown'} ${s.cwd}`);
|
|
37
|
-
console.log(` pane ${s.pane ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}`);
|
|
40
|
+
console.log(` pane ${s.pane ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}${hold}`);
|
|
38
41
|
}
|
|
39
42
|
return 0;
|
|
40
43
|
}
|
|
@@ -46,15 +49,30 @@ function selectKeys(state, idOrAll, statuses = ['stopped']) {
|
|
|
46
49
|
return match.map(s => s.key);
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
export function cmdResumeNow(idOrAll) {
|
|
52
|
+
export async function cmdResumeNow(idOrAll) {
|
|
50
53
|
const state = readState();
|
|
51
54
|
const keys = selectKeys(state, idOrAll);
|
|
52
55
|
if (keys.length === 0) { console.log('unsnooze: no matching stopped sessions.'); return 1; }
|
|
56
|
+
// The commenter's "show me the diff": held records print what moved since
|
|
57
|
+
// the stop-time baseline before we wake them. Best-effort, never blocking.
|
|
58
|
+
for (const key of keys) {
|
|
59
|
+
const rec = state.sessions[key];
|
|
60
|
+
if (rec?.workspaceHold && rec.workspace?.head && rec.cwd) {
|
|
61
|
+
try {
|
|
62
|
+
const { execFileSync } = await import('node:child_process');
|
|
63
|
+
const stat = execFileSync('git', ['-C', rec.cwd, 'diff', '--stat', `${rec.workspace.head}..HEAD`],
|
|
64
|
+
{ stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).toString().trim();
|
|
65
|
+
if (stat) console.log(`unsnooze: workspace changes since ${key.slice(0, 12)} stopped:\n${stat}`);
|
|
66
|
+
} catch { /* repo gone or git unhappy — proceed anyway */ }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
53
69
|
updateState(s => {
|
|
54
70
|
for (const key of keys) {
|
|
55
71
|
if (s.sessions[key]) {
|
|
56
72
|
s.sessions[key].resetAt = Date.now();
|
|
57
|
-
s.sessions[key].manual = true; // explicit user action beats autoResume=off
|
|
73
|
+
s.sessions[key].manual = true; // explicit user action beats autoResume=off + workspaceGuard
|
|
74
|
+
delete s.sessions[key].workspaceHold;
|
|
75
|
+
delete s.sessions[key].holdReason;
|
|
58
76
|
}
|
|
59
77
|
}
|
|
60
78
|
});
|
package/src/resumer.js
CHANGED
|
@@ -18,6 +18,7 @@ import { getAgent } from './agents/index.js';
|
|
|
18
18
|
import { parseResetTime, resetAtMs } from './time-parser.js';
|
|
19
19
|
import { readState, updateState, setStatus, dueSessions, activeStopped } from './state.js';
|
|
20
20
|
import { getConfig, resolveResumeMessage } from './settings.js';
|
|
21
|
+
import { workspaceFingerprint, workspaceChanged, describeChange } from './workspace.js';
|
|
21
22
|
import { notify } from './notify.js';
|
|
22
23
|
import { UNSNOOZE_BIN } from './spawn.js';
|
|
23
24
|
import { makeLogger } from './logger.js';
|
|
@@ -51,7 +52,8 @@ export function releaseSingleton() {
|
|
|
51
52
|
// it's off. Stops stay tracked either way.
|
|
52
53
|
export function dueForDispatch(now = Date.now()) {
|
|
53
54
|
const auto = getConfig('autoResume');
|
|
54
|
-
|
|
55
|
+
// workspaceHold: guarded sessions wait for an explicit resume-now (manual).
|
|
56
|
+
return dueSessions(now).filter(s => (auto || s.manual) && (!s.workspaceHold || s.manual));
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
function shellQuote(arg) {
|
|
@@ -69,7 +71,7 @@ function selfCommand() {
|
|
|
69
71
|
|
|
70
72
|
// Decide how to act on one due record. Pure-ish; tmux injectable.
|
|
71
73
|
// Returns: 'sent' | 'reopened' | 'deferred' | 'skip' | 'failed'
|
|
72
|
-
export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd = selfCommand() } = {}) {
|
|
74
|
+
export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd = selfCommand(), fingerprint = workspaceFingerprint, notifier = notify } = {}) {
|
|
73
75
|
const key = rec.key;
|
|
74
76
|
const agent = getAgent(rec.agent);
|
|
75
77
|
// Wake-message precedence: per-session (`unsnooze message <id> "..."`) →
|
|
@@ -77,6 +79,24 @@ export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd
|
|
|
77
79
|
// both the live-pane sendText and the argv reopen path.
|
|
78
80
|
resumeMessage = rec.resumeMessage ?? resumeMessage ?? resolveResumeMessage(agent.id);
|
|
79
81
|
|
|
82
|
+
// Stale-workspace guard: another session (or a human) may have moved the
|
|
83
|
+
// repo while this one slept. Manual resumes (resume-now) always proceed.
|
|
84
|
+
const guardMode = getConfig('workspaceGuard');
|
|
85
|
+
if (rec.workspace && guardMode !== 'off' && !rec.manual) {
|
|
86
|
+
const change = workspaceChanged(rec, fingerprint(rec.cwd));
|
|
87
|
+
if (change) {
|
|
88
|
+
const desc = describeChange(change);
|
|
89
|
+
if (guardMode === 'pause') {
|
|
90
|
+
setStatus(key, 'stopped', { workspaceHold: true, holdReason: desc });
|
|
91
|
+
notifier('unsnooze: session held', `${rec.cwd}: workspace changed while stopped (${desc}) — run: unsnooze resume-now`);
|
|
92
|
+
log(`${key}: workspace changed (${desc}) — held (workspaceGuard=pause)`);
|
|
93
|
+
return 'held';
|
|
94
|
+
}
|
|
95
|
+
resumeMessage += `\n\nHeads up: this workspace changed while the session was stopped (${desc}). Re-read the current state of the repo before continuing.`;
|
|
96
|
+
log(`${key}: workspace changed (${desc}) — informing agent in the wake message`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
80
100
|
// Live-pane path: only if the pane still exists AND the agent CLI is its
|
|
81
101
|
// foreground command (pane ids get recycled — never inject into a random
|
|
82
102
|
// program).
|
package/src/settings.js
CHANGED
|
@@ -18,6 +18,7 @@ export const DEFAULTS = {
|
|
|
18
18
|
notifications: true, // desktop notifications on detect/resume
|
|
19
19
|
guiWatch: true, // daemon watches transcripts/rollouts for GUI-session stops
|
|
20
20
|
updateCheck: true, // daily registry version check + update notices/toast
|
|
21
|
+
workspaceGuard: 'inform', // repo changed while stopped: off | inform | pause
|
|
21
22
|
resumeMessage: 'Continue where you left off. The session was interrupted by a usage limit which has now reset — pick up the task you were working on and finish it.',
|
|
22
23
|
resumeMessages: { claude: '', codex: '', grok: '' }, // per-agent override; '' = use resumeMessage
|
|
23
24
|
agents: { claude: true, codex: true, grok: false }, // grok is experimental
|
|
@@ -30,6 +31,7 @@ const ENV_NAMES = {
|
|
|
30
31
|
notifications: 'UNSNOOZE_NOTIFICATIONS',
|
|
31
32
|
guiWatch: 'UNSNOOZE_GUI_WATCH',
|
|
32
33
|
updateCheck: 'UNSNOOZE_UPDATE_CHECK',
|
|
34
|
+
workspaceGuard: 'UNSNOOZE_WORKSPACE_GUARD',
|
|
33
35
|
resumeMessage: 'UNSNOOZE_RESUME_MESSAGE',
|
|
34
36
|
'resumeMessages.claude': 'UNSNOOZE_RESUME_MESSAGE_CLAUDE',
|
|
35
37
|
'resumeMessages.codex': 'UNSNOOZE_RESUME_MESSAGE_CODEX',
|
|
@@ -41,6 +43,11 @@ const ENV_NAMES = {
|
|
|
41
43
|
|
|
42
44
|
const KNOWN_KEYS = Object.keys(ENV_NAMES);
|
|
43
45
|
|
|
46
|
+
// String settings restricted to a fixed set of values.
|
|
47
|
+
const ENUMS = {
|
|
48
|
+
workspaceGuard: ['off', 'inform', 'pause'],
|
|
49
|
+
};
|
|
50
|
+
|
|
44
51
|
function parseBool(raw) {
|
|
45
52
|
if (/^(1|true|on|yes)$/i.test(raw)) return true;
|
|
46
53
|
if (/^(0|false|off|no)$/i.test(raw)) return false;
|
|
@@ -98,6 +105,9 @@ export function setConfigValue(key, rawValue) {
|
|
|
98
105
|
value = b;
|
|
99
106
|
} else {
|
|
100
107
|
value = String(rawValue);
|
|
108
|
+
if (ENUMS[key] && !ENUMS[key].includes(value)) {
|
|
109
|
+
throw new Error(`unsnooze: "${key}" must be one of: ${ENUMS[key].join(', ')}`);
|
|
110
|
+
}
|
|
101
111
|
}
|
|
102
112
|
const config = readFileConfig();
|
|
103
113
|
const parts = key.split('.');
|
package/src/state.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
STATE_DIR, STATE_FILE, LOCK_DIR, STALE_LOCK_MS, PRUNE_AFTER_MS,
|
|
14
14
|
DEDUPE_WINDOW_MS,
|
|
15
15
|
} from './config.js';
|
|
16
|
+
import { workspaceFingerprint } from './workspace.js';
|
|
16
17
|
import { makeLogger } from './logger.js';
|
|
17
18
|
|
|
18
19
|
const log = makeLogger('state');
|
|
@@ -105,6 +106,11 @@ export function upsertSession(record) {
|
|
|
105
106
|
log(`merged duplicate detection for pane ${record.pane} into ${existingKey}`);
|
|
106
107
|
return state;
|
|
107
108
|
}
|
|
109
|
+
// Baseline for the stale-workspace guard, captured once at stop time.
|
|
110
|
+
// (Merged duplicates above keep the ORIGINAL baseline — spread semantics.)
|
|
111
|
+
if (record.status === 'stopped' && record.workspace === undefined) {
|
|
112
|
+
record.workspace = workspaceFingerprint(record.cwd);
|
|
113
|
+
}
|
|
108
114
|
const key = record.sessionId || `pane:${record.pane}:${record.detectedAt}`;
|
|
109
115
|
state.sessions[key] = { ...record, key };
|
|
110
116
|
return state;
|
package/src/update-check.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// The check is a plain GET to registry.npmjs.org — nothing identifying.
|
|
9
9
|
|
|
10
10
|
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
11
12
|
import { join, dirname } from 'node:path';
|
|
12
13
|
import { fileURLToPath } from 'node:url';
|
|
13
14
|
import { getConfig } from './settings.js';
|
|
@@ -75,7 +76,7 @@ export function updateNotice(pkgVersion = PKG_VERSION) {
|
|
|
75
76
|
if (!getConfig('updateCheck')) return null;
|
|
76
77
|
const { latest } = readCache();
|
|
77
78
|
if (!latest || !isNewer(latest, pkgVersion)) return null;
|
|
78
|
-
return `unsnooze ${latest} is available (you have ${pkgVersion}) —
|
|
79
|
+
return `unsnooze ${latest} is available (you have ${pkgVersion}) — run: unsnooze update · ${RELEASES_URL}`;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
// The "## <version>" block from the CHANGELOG.md that ships in the tarball.
|
|
@@ -113,8 +114,35 @@ export async function runUpdateCheck({ fetcher, notifier = notify, now = Date.no
|
|
|
113
114
|
if (!latest) return 0;
|
|
114
115
|
const cache = writeCache({ lastCheckedAt: now, latest });
|
|
115
116
|
if (isNewer(latest, PKG_VERSION) && cache.notifiedVersion !== latest) {
|
|
116
|
-
notifier('unsnooze update available', `${latest} is out (you have ${PKG_VERSION}) —
|
|
117
|
+
notifier('unsnooze update available', `${latest} is out (you have ${PKG_VERSION}) — run: unsnooze update`);
|
|
117
118
|
writeCache({ notifiedVersion: latest });
|
|
118
119
|
}
|
|
119
120
|
return 0;
|
|
120
121
|
}
|
|
122
|
+
|
|
123
|
+
// `unsnooze update` — self-update via npm, then show what changed. npm -g
|
|
124
|
+
// overwrites this install in place, so re-reading package.json/CHANGELOG.md
|
|
125
|
+
// after a successful install yields the NEW version's info.
|
|
126
|
+
export function runSelfUpdate({ runner = spawnSync, print = console.log } = {}) {
|
|
127
|
+
print(`unsnooze ${PKG_VERSION} — updating via npm install -g unsnooze@latest …`);
|
|
128
|
+
const r = runner('npm', ['install', '-g', 'unsnooze@latest'], { stdio: 'inherit' });
|
|
129
|
+
if (r.error || r.status !== 0) {
|
|
130
|
+
print(`unsnooze: update failed (${r.error ? r.error.message : `npm exited ${r.status}`}).`);
|
|
131
|
+
print('unsnooze: try it manually: npm install -g unsnooze@latest');
|
|
132
|
+
print('unsnooze: (permission errors usually mean your npm prefix needs sudo or a user-writable prefix)');
|
|
133
|
+
return r.status || 1;
|
|
134
|
+
}
|
|
135
|
+
let newVersion = PKG_VERSION;
|
|
136
|
+
try {
|
|
137
|
+
newVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version;
|
|
138
|
+
} catch { /* moved install dir — the generic message below still holds */ }
|
|
139
|
+
if (newVersion === PKG_VERSION) {
|
|
140
|
+
print(`unsnooze: already up to date (${PKG_VERSION}).`);
|
|
141
|
+
} else {
|
|
142
|
+
const section = changelogSection(newVersion);
|
|
143
|
+
print(`unsnooze: updated to ${newVersion}.${section ? ` What's new:\n${section}` : ''}`);
|
|
144
|
+
}
|
|
145
|
+
// Don't repeat "what's new" on the next command.
|
|
146
|
+
writeCache({ lastRunVersion: newVersion });
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
package/src/workspace.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Stale-workspace guard: fingerprint a repo when a session stops, compare at
|
|
2
|
+
// wake time. If another session (or a human) moved the repo meanwhile, the
|
|
3
|
+
// resumer either warns the agent in the wake message or holds the session,
|
|
4
|
+
// per the `workspaceGuard` setting. Non-git directories fingerprint to null
|
|
5
|
+
// and are never guarded.
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
function git(cwd, args) {
|
|
11
|
+
return execFileSync('git', ['-C', cwd, ...args], {
|
|
12
|
+
stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500,
|
|
13
|
+
}).toString().trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function workspaceFingerprint(cwd) {
|
|
17
|
+
if (!cwd) return null;
|
|
18
|
+
try {
|
|
19
|
+
const head = git(cwd, ['rev-parse', 'HEAD']);
|
|
20
|
+
const dirty = git(cwd, ['status', '--porcelain']);
|
|
21
|
+
return { head, dirtyHash: createHash('sha1').update(dirty).digest('hex') };
|
|
22
|
+
} catch {
|
|
23
|
+
return null; // not a git repo / git missing / unreadable — skip the guard
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// null → nothing to report (no baseline, no current, or identical).
|
|
28
|
+
export function workspaceChanged(rec, current) {
|
|
29
|
+
const before = rec?.workspace;
|
|
30
|
+
if (!before || !current) return null;
|
|
31
|
+
if (before.head === current.head && before.dirtyHash === current.dirtyHash) return null;
|
|
32
|
+
return {
|
|
33
|
+
oldHead: before.head,
|
|
34
|
+
newHead: current.head,
|
|
35
|
+
dirtyChanged: before.dirtyHash !== current.dirtyHash,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function describeChange(d) {
|
|
40
|
+
const parts = [];
|
|
41
|
+
if (d.oldHead !== d.newHead) parts.push(`HEAD ${d.oldHead.slice(0, 7)} → ${d.newHead.slice(0, 7)}`);
|
|
42
|
+
if (d.dirtyChanged) parts.push('uncommitted changes differ');
|
|
43
|
+
return parts.join('; ');
|
|
44
|
+
}
|