@goodandready/dsh-time-machine 0.1.3 → 0.1.5

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/lib/client.js CHANGED
@@ -53,6 +53,8 @@ window.__ModuleLoader__.load({
53
53
  empty: 'No checkpoints yet.',
54
54
  diff: 'Diff',
55
55
  rollback: 'Rollback',
56
+ delete: 'Delete',
57
+ confirmDelete: 'Delete this checkpoint?',
56
58
  confirmRollback: 'Rollback to this checkpoint? This will reset the workspace.',
57
59
  close: 'Close',
58
60
  save: 'Save',
@@ -73,6 +75,8 @@ window.__ModuleLoader__.load({
73
75
  empty: 'Чекпоинтов пока нет.',
74
76
  diff: 'Diff',
75
77
  rollback: 'Откатить',
78
+ delete: 'Удалить',
79
+ confirmDelete: 'Удалить этот чекпоинт?',
76
80
  confirmRollback: 'Откатиться к этому чекпоинту? Рабочая копия будет сброшена.',
77
81
  close: 'Закрыть',
78
82
  save: 'Сохранить',
@@ -104,7 +108,24 @@ window.__ModuleLoader__.load({
104
108
  return lang;
105
109
  }
106
110
 
107
- function Timeline({ t }) {
111
+ function sessionIdOf(ctx) {
112
+ try {
113
+ if (!ctx) return '';
114
+ if (ctx.scope) {
115
+ if (ctx.scope.session) return String(ctx.scope.session.id || ctx.scope.session.header?.id || '');
116
+ if (typeof ctx.scope.id === 'string') return ctx.scope.id;
117
+ }
118
+ if (ctx.session) return String(ctx.session.id || ctx.session.header?.id || '');
119
+ if (ctx.get && typeof ctx.get === 'function') {
120
+ const s = ctx.get('session');
121
+ if (s) return String(s.id || s.header?.id || '');
122
+ }
123
+ } catch {}
124
+ return '';
125
+ }
126
+
127
+ function Timeline({ t, ctx }) {
128
+ const sid = sessionIdOf(ctx);
108
129
  const [snapshots, setSnapshots] = React.useState([]);
109
130
  const [loading, setLoading] = React.useState(false);
110
131
  const [label, setLabel] = React.useState('');
@@ -114,17 +135,18 @@ window.__ModuleLoader__.load({
114
135
 
115
136
  const fetchList = React.useCallback(() => {
116
137
  setLoading(true); setErr('');
117
- fetch('/dsh-time-machine/snapshots').then(r => r.json()).then(j => {
138
+ const q = sid ? '?sessionId=' + encodeURIComponent(sid) : '';
139
+ fetch('/dsh-time-machine/snapshots' + q).then(r => r.json()).then(j => {
118
140
  if (j.success) setSnapshots(j.snapshots || []);
119
141
  else setErr(j.error || 'load failed');
120
142
  }).catch(e => setErr(String(e.message || e))).finally(() => setLoading(false));
121
- }, []);
143
+ }, [sid]);
122
144
 
123
145
  React.useEffect(() => { fetchList(); }, [fetchList]);
124
146
 
125
147
  const onCreate = () => {
126
148
  setBusy(true); setErr('');
127
- fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) })
149
+ fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label, sessionId: sid }) })
128
150
  .then(r => r.json()).then(j => {
129
151
  if (!j.success) throw new Error(j.error || 'create failed');
130
152
  setLabel(''); fetchList();
@@ -146,6 +168,15 @@ window.__ModuleLoader__.load({
146
168
  fetchList();
147
169
  }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
148
170
  };
171
+ const onDelete = (id) => {
172
+ if (!window.confirm(t.confirmDelete)) return;
173
+ setBusy(true); setErr('');
174
+ fetch('/dsh-time-machine/delete', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, confirm: true }) })
175
+ .then(r => r.json()).then(j => {
176
+ if (!j.success) throw new Error(j.error || 'delete failed');
177
+ fetchList();
178
+ }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
179
+ };
149
180
 
150
181
  return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } },
151
182
  React.createElement('div', { style: { display: 'flex', gap: 8 } },
@@ -162,7 +193,8 @@ window.__ModuleLoader__.load({
162
193
  React.createElement('span', { className: 'tm-row-meta' }, new Date(s.createdAt).toLocaleString() + (s.commit ? ' · ' + s.commit.slice(0,7) : ''))
163
194
  ),
164
195
  React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onDiff(s.id) }, t.diff),
165
- React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onRollback(s.id) }, t.rollback)
196
+ React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onRollback(s.id) }, t.rollback),
197
+ React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onDelete(s.id) }, t.delete)
166
198
  ))
167
199
  ),
168
200
  diffText !== null ? React.createElement('div', { className: 'tm-modal', onClick: () => setDiffText(null) },
@@ -179,6 +211,7 @@ window.__ModuleLoader__.load({
179
211
 
180
212
  function PluginCard(props) {
181
213
  const ctx = props.ctx;
214
+ const sid = sessionIdOf(ctx);
182
215
  const [expanded, setExpanded] = React.useState(false);
183
216
  const lang = useLocale(ctx);
184
217
  const t = lang === 'ru' ? ru : en;
@@ -225,17 +258,18 @@ window.__ModuleLoader__.load({
225
258
 
226
259
  const fetchList = React.useCallback(() => {
227
260
  setLoading(true); setErr('');
228
- fetch('/dsh-time-machine/snapshots').then(r => r.json()).then(j => {
261
+ const q = sid ? '?sessionId=' + encodeURIComponent(sid) : '';
262
+ fetch('/dsh-time-machine/snapshots' + q).then(r => r.json()).then(j => {
229
263
  if (j.success) setSnapshots(j.snapshots || []);
230
264
  else setErr(j.error || 'load failed');
231
265
  }).catch(e => setErr(String(e.message || e))).finally(() => setLoading(false));
232
- }, []);
266
+ }, [sid]);
233
267
 
234
268
  React.useEffect(() => { if (expanded) fetchList(); }, [expanded, fetchList]);
235
269
 
236
270
  const onCreate = () => {
237
271
  setBusy(true); setErr('');
238
- fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) })
272
+ fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label, sessionId: sid }) })
239
273
  .then(r => r.json()).then(j => {
240
274
  if (!j.success) throw new Error(j.error || 'create failed');
241
275
  setLabel(''); fetchList();
@@ -257,6 +291,15 @@ window.__ModuleLoader__.load({
257
291
  fetchList();
258
292
  }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
259
293
  };
294
+ const onDelete = (id) => {
295
+ if (!window.confirm(t.confirmDelete)) return;
296
+ setBusy(true); setErr('');
297
+ fetch('/dsh-time-machine/delete', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, confirm: true }) })
298
+ .then(r => r.json()).then(j => {
299
+ if (!j.success) throw new Error(j.error || 'delete failed');
300
+ fetchList();
301
+ }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
302
+ };
260
303
  const onSave = async () => {
261
304
  if (!scope) return;
262
305
  setSaving(true); setErr('');
@@ -318,7 +361,8 @@ window.__ModuleLoader__.load({
318
361
  React.createElement('span', { className: 'tm-row-meta' }, new Date(s.createdAt).toLocaleString() + (s.commit ? ' · ' + s.commit.slice(0,7) : ''))
319
362
  ),
320
363
  React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onDiff(s.id) }, t.diff),
321
- React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onRollback(s.id) }, t.rollback)
364
+ React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onRollback(s.id) }, t.rollback),
365
+ React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onDelete(s.id) }, t.delete)
322
366
  ))
323
367
  )
324
368
  ),
@@ -342,31 +386,10 @@ window.__ModuleLoader__.load({
342
386
  return React.createElement('div', { className: 'tm-tab' },
343
387
  React.createElement('div', { style: { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, t.tabTitle),
344
388
  React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: 4 } }, t.sub),
345
- React.createElement(Timeline, { t })
389
+ React.createElement(Timeline, { t, ctx })
346
390
  );
347
391
  }
348
392
 
349
- function registerBetterSidebar(ctx) {
350
- const svc = (ctx.get && (()=>{ try{return ctx.get('betterSidebar')}catch{return null}})()) || ctx.betterSidebar;
351
- if (!svc || typeof svc.registerTab !== 'function') return;
352
- try {
353
- svc.registerTab({
354
- id: 'time-machine',
355
- title: () => {
356
- const a = ctx.locale && ctx.locale.getSnapshot && ctx.locale.getSnapshot().active || 'en';
357
- return a.startsWith('ru') ? 'Машина времени' : 'Time Machine';
358
- },
359
- order: 30,
360
- single: true,
361
- icon: (size) => React.createElement('svg', { width: size||16, height: size||16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' },
362
- React.createElement('circle', { cx:12, cy:12, r:10 }),
363
- React.createElement('polyline', { points:'12 6 12 12 16 14' })
364
- ),
365
- component: (p) => React.createElement(TimeMachineTab, { ctx, ...p })
366
- });
367
- } catch (e) { console.warn('[dsh-time-machine] betterSidebar tab failed', e); }
368
- }
369
-
370
393
  module.exports.inject = ['slots', 'locale'];
371
394
  module.exports.apply = function apply(ctx) {
372
395
  ensureStyles();
package/lib/index.js CHANGED
@@ -12,6 +12,20 @@ export const Config = Schema.object({
12
12
 
13
13
  const NS = '@goodandready/dsh-time-machine';
14
14
 
15
+ function sessionIdOf(execution, ctx) {
16
+ try {
17
+ if (execution && execution.sessionId) return String(execution.sessionId);
18
+ if (execution && execution.agent && execution.agent.session) {
19
+ return String(execution.agent.session.id || execution.agent.session.header?.id || '');
20
+ }
21
+ if (ctx && ctx.session) return String(ctx.session.id || ctx.session.header?.id || '');
22
+ if (ctx && ctx.get && typeof ctx.get === 'function') {
23
+ try { const s = ctx.get('session'); if (s) return String(s.id || s.header?.id || ''); } catch {}
24
+ }
25
+ } catch {}
26
+ return '';
27
+ }
28
+
15
29
  export function apply(ctx, config) {
16
30
  let getConfig = () => config;
17
31
  let engine = new ShadowSnapshotEngine({ maxSnapshots: config?.maxSnapshots ?? 20 });
@@ -25,30 +39,86 @@ export function apply(ctx, config) {
25
39
  sctx.effect(() => () => { try { stop(); } catch {} }, 'dsh-time-machine: settings watch');
26
40
  });
27
41
 
28
- // tools
42
+ // auto-snapshot on turn/start and approval/asked (session-scoped)
43
+ const autoSnap = async (label, sessId) => {
44
+ if (!getConfig().autoSnapshotEnabled) return;
45
+ try {
46
+ const sid = String(sessId || '').trim();
47
+ await engine.createSnapshot(label, { sessionId: sid });
48
+ } catch {}
49
+ };
50
+
51
+ // listen to DSH events if available
52
+ try {
53
+ if (ctx.events && typeof ctx.events.on === 'function') {
54
+ ctx.effect(() => {
55
+ const offs = [];
56
+ try {
57
+ offs.push(ctx.events.on('turn/start', (ev) => {
58
+ const sid = sessionIdOf(ev, ctx);
59
+ autoSnap(`auto:turn:${ev?.turnId || Date.now()}`, sid);
60
+ }));
61
+ } catch {}
62
+ try {
63
+ offs.push(ctx.events.on('approval/asked', (ev) => {
64
+ const sid = sessionIdOf(ev, ctx);
65
+ const tool = ev?.tool || ev?.name || 'approval';
66
+ autoSnap(`auto:approval:${tool}`, sid);
67
+ }));
68
+ } catch {}
69
+ try {
70
+ offs.push(ctx.events.on('turn/end', (ev) => {
71
+ if (!getConfig().autoSnapshotEnabled) return;
72
+ const outcome = String((ev && (ev.outcome || ev.result?.outcome || ev.result?.status)) || '');
73
+ const sid = sessionIdOf(ev, ctx);
74
+ if (!sid) return;
75
+ if (outcome === 'success' || outcome === 'ok' || outcome === 'done') {
76
+ engine.pruneSnapshots(sid, 3).catch(() => {});
77
+ }
78
+ // failure/blocked: keep all checkpoints for review
79
+ }));
80
+ } catch {}
81
+ return () => { for (const off of offs) try { typeof off === 'function' && off(); } catch {} };
82
+ }, 'dsh-time-machine: auto snapshot events');
83
+ }
84
+ } catch {}
85
+
86
+ // tools (session-aware)
29
87
  const registerTools = (tctx) => {
30
88
  tctx.tools.register({
31
89
  name: 'time_machine_checkpoint_create',
32
- description: 'Create a workspace checkpoint (shadow git snapshot) before risky changes. Returns id and label.',
90
+ description: 'Create a workspace checkpoint (shadow git snapshot) before risky changes. Returns id and label. Auto-creates per session if autoSnapshotEnabled.',
33
91
  parameters: {
34
92
  type: 'object',
35
93
  properties: {
36
94
  label: { type: 'string', description: 'Human label for checkpoint' },
95
+ sessionId: { type: 'string', description: 'Session id to scope checkpoint (auto-detected if omitted)' },
37
96
  },
38
97
  },
39
- execute: async ({ label } = {}) => {
40
- const snap = await engine.createSnapshot(label);
98
+ execute: async (args = {}, execution) => {
99
+ const sid = String(args.sessionId || sessionIdOf(execution, tctx) || sessionIdOf(args, tctx) || '').trim();
100
+ const snap = await engine.createSnapshot(args.label, { sessionId: sid });
41
101
  return { success: true, snapshot: snap };
42
102
  },
43
103
  });
44
104
 
45
105
  tctx.tools.register({
46
106
  name: 'time_machine_checkpoint_list',
47
- description: 'List recent workspace checkpoints newest first.',
48
- parameters: { type: 'object', properties: {} },
49
- execute: async () => {
50
- const list = engine.listSnapshots();
51
- return { success: true, snapshots: list };
107
+ description: 'List recent workspace checkpoints newest first. Filter by sessionId if given.',
108
+ parameters: {
109
+ type: 'object',
110
+ properties: {
111
+ sessionId: { type: 'string', description: 'Filter by session id' },
112
+ },
113
+ },
114
+ execute: async (args = {}, execution) => {
115
+ const sid = args.sessionId != null ? String(args.sessionId).trim() : (sessionIdOf(execution, tctx) ? String(sessionIdOf(execution, tctx)).trim() : undefined);
116
+ const list = sid !== undefined ? engine.listSnapshots(sid) : engine.listSnapshots();
117
+ // if sid undefined and we want all, return all; but if sessionId was auto-detected, filter
118
+ // For backward compat, if no sid provided and engine has sessionIds, return all
119
+ // If sid was auto-detected (execution has session), use it
120
+ const finalList = (args.sessionId == null && sid !== undefined && sid !== '') ? engine.listSnapshots(sid) : (args.sessionId != null ? engine.listSnapshots(String(args.sessionId)) : engine.listSnapshots());
121
+ return { success: true, snapshots: finalList, sessionId: sid || '' };
52
122
  },
53
123
  });
54
124
 
@@ -85,6 +155,40 @@ export function apply(ctx, config) {
85
155
  return { success: true, ...res };
86
156
  },
87
157
  });
158
+
159
+ tctx.tools.register({
160
+ name: 'time_machine_checkpoint_delete',
161
+ description: 'Delete a single checkpoint. Requires confirm:true.',
162
+ parameters: {
163
+ type: 'object',
164
+ properties: {
165
+ id: { type: 'string', description: 'Snapshot id to delete' },
166
+ confirm: { type: 'boolean', description: 'Must be true to confirm deletion' },
167
+ },
168
+ required: ['id', 'confirm'],
169
+ },
170
+ execute: async ({ id, confirm } = {}) => {
171
+ const res = await engine.deleteSnapshot(String(id ?? ''), { confirm });
172
+ return { success: true, ...res };
173
+ },
174
+ });
175
+
176
+ tctx.tools.register({
177
+ name: 'time_machine_checkpoint_prune',
178
+ description: 'Prune session checkpoints, keeping only the newest N (default 3).',
179
+ parameters: {
180
+ type: 'object',
181
+ properties: {
182
+ sessionId: { type: 'string', description: 'Session id to prune (default current session)' },
183
+ keep: { type: 'number', description: 'How many newest checkpoints to keep (default 3)' },
184
+ },
185
+ },
186
+ execute: async (args = {}, execution) => {
187
+ const sid = String(args.sessionId || sessionIdOf(execution, tctx) || '').trim();
188
+ const out = await engine.pruneSnapshots(sid, args.keep);
189
+ return { success: true, sessionId: sid, ...out };
190
+ },
191
+ });
88
192
  };
89
193
 
90
194
  try {
@@ -92,7 +196,7 @@ export function apply(ctx, config) {
92
196
  else ctx.inject(['tools'], (tctx) => registerTools(tctx));
93
197
  } catch {}
94
198
 
95
- // web routes for UI
199
+ // web routes for UI (session-aware via ?sessionId=)
96
200
  ctx.effect(() => {
97
201
  const disposals = [];
98
202
  try {
@@ -100,8 +204,11 @@ export function apply(ctx, config) {
100
204
  kind: 'exact',
101
205
  path: '/dsh-time-machine/snapshots',
102
206
  handler: (req, res) => {
207
+ const url = new URL(req.url, 'http://localhost');
208
+ const sid = url.searchParams.get('sessionId');
209
+ const list = sid != null ? engine.listSnapshots(String(sid)) : engine.listSnapshots();
103
210
  res.setHeader('content-type', 'application/json');
104
- res.end(JSON.stringify({ success: true, snapshots: engine.listSnapshots() }));
211
+ res.end(JSON.stringify({ success: true, snapshots: list }));
105
212
  },
106
213
  }));
107
214
  disposals.push(ctx.webServer.register({
@@ -131,7 +238,8 @@ export function apply(ctx, config) {
131
238
  req.on('end', async () => {
132
239
  try {
133
240
  const parsed = body ? JSON.parse(body) : {};
134
- const snap = await engine.createSnapshot(parsed.label);
241
+ const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '').trim();
242
+ const snap = await engine.createSnapshot(parsed.label, { sessionId: sid });
135
243
  res.setHeader('content-type', 'application/json');
136
244
  res.end(JSON.stringify({ success: true, snapshot: snap }));
137
245
  } catch (e) {
@@ -142,6 +250,47 @@ export function apply(ctx, config) {
142
250
  });
143
251
  },
144
252
  }));
253
+ disposals.push(ctx.webServer.register({
254
+ kind: 'exact',
255
+ path: '/dsh-time-machine/delete',
256
+ handler: async (req, res) => {
257
+ let body = '';
258
+ req.on('data', (c) => body += c);
259
+ req.on('end', async () => {
260
+ try {
261
+ const parsed = body ? JSON.parse(body) : {};
262
+ const out = await engine.deleteSnapshot(String(parsed.id || ''), { confirm: parsed.confirm });
263
+ res.setHeader('content-type', 'application/json');
264
+ res.end(JSON.stringify({ success: true, ...out }));
265
+ } catch (e) {
266
+ res.statusCode = 400;
267
+ res.setHeader('content-type', 'application/json');
268
+ res.end(JSON.stringify({ success: false, error: String(e.message || e), code: e.code || '' }));
269
+ }
270
+ });
271
+ },
272
+ }));
273
+ disposals.push(ctx.webServer.register({
274
+ kind: 'exact',
275
+ path: '/dsh-time-machine/prune',
276
+ handler: async (req, res) => {
277
+ let body = '';
278
+ req.on('data', (c) => body += c);
279
+ req.on('end', async () => {
280
+ try {
281
+ const parsed = body ? JSON.parse(body) : {};
282
+ const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '');
283
+ const out = await engine.pruneSnapshots(sid, parsed.keep);
284
+ res.setHeader('content-type', 'application/json');
285
+ res.end(JSON.stringify({ success: true, sessionId: sid, ...out }));
286
+ } catch (e) {
287
+ res.statusCode = 400;
288
+ res.setHeader('content-type', 'application/json');
289
+ res.end(JSON.stringify({ success: false, error: String(e.message || e) }));
290
+ }
291
+ });
292
+ },
293
+ }));
145
294
  disposals.push(ctx.webServer.register({
146
295
  kind: 'exact',
147
296
  path: '/dsh-time-machine/rollback',
@@ -166,6 +315,5 @@ export function apply(ctx, config) {
166
315
  return () => { for (const d of disposals) try { typeof d === 'function' && d(); } catch {} };
167
316
  }, 'dsh-time-machine: web routes');
168
317
 
169
- // expose engine for tests
170
318
  ctx.provide?.('timeMachineEngine', engine);
171
319
  }
package/lib/snapshot.js CHANGED
@@ -16,19 +16,29 @@ async function defaultExec(cmd, args, opts = {}) {
16
16
  }
17
17
  }
18
18
 
19
- // ponytail: in-memory engine, git shadow refs if repo present; O(n) scan, global lock
19
+ // ponytail: in-memory engine, git shadow refs if repo present; O(n) scan, per-session trim
20
20
  export class ShadowSnapshotEngine {
21
21
  constructor({ exec = defaultExec, maxSnapshots = 20, cwd } = {}) {
22
22
  this.exec = exec;
23
23
  this.maxSnapshots = maxSnapshots;
24
24
  this.cwd = cwd;
25
- this.snapshots = []; // newest last, list returns newest first
25
+ this.snapshots = []; // {id, label, sessionId, createdAt, commit, ref}
26
+ this.seq = 0;
26
27
  }
27
28
 
28
29
  _now() { return Date.now(); }
29
30
 
30
- _trim() {
31
- while (this.snapshots.length > this.maxSnapshots) this.snapshots.shift();
31
+ _trimFor(sessionId) {
32
+ if (!sessionId) {
33
+ while (this.snapshots.length > this.maxSnapshots) this.snapshots.shift();
34
+ return;
35
+ }
36
+ const perSession = this.snapshots.filter(s => s.sessionId === sessionId);
37
+ while (perSession.length > this.maxSnapshots) {
38
+ const oldest = perSession.shift();
39
+ const idx = this.snapshots.indexOf(oldest);
40
+ if (idx !== -1) this.snapshots.splice(idx, 1);
41
+ }
32
42
  }
33
43
 
34
44
  async _isGitRepo() {
@@ -38,36 +48,38 @@ export class ShadowSnapshotEngine {
38
48
  } catch { return false; }
39
49
  }
40
50
 
41
- async createSnapshot(label = '') {
51
+ async createSnapshot(label = '', { sessionId = '' } = {}) {
42
52
  const id = crypto.randomUUID().slice(0, 8);
43
53
  const createdAt = this._now();
44
54
  const safeLabel = String(label ?? '').trim() || `checkpoint-${id}`;
55
+ const sid = String(sessionId || '').trim();
45
56
  let commit = null;
46
57
  let ref = null;
47
58
  const inGit = await this._isGitRepo();
48
59
  if (inGit) {
49
60
  try {
50
- // stage working tree without committing to main branch
51
61
  await this.exec('git', ['add', '-A'], this.cwd ? { cwd: this.cwd } : {});
52
62
  const { stdout: tree } = await this.exec('git', ['write-tree'], this.cwd ? { cwd: this.cwd } : {});
53
63
  const treeHash = String(tree).trim();
54
64
  const { stdout: commitHash } = await this.exec('git', ['commit-tree', treeHash, '-m', safeLabel], this.cwd ? { cwd: this.cwd } : {});
55
65
  commit = String(commitHash).trim();
56
- ref = `refs/dsh-time-machine/${id}`;
66
+ ref = sid ? `refs/dsh-time-machine/${sid}/${id}` : `refs/dsh-time-machine/${id}`;
57
67
  await this.exec('git', ['update-ref', ref, commit], this.cwd ? { cwd: this.cwd } : {});
58
68
  } catch {
59
69
  commit = null;
60
70
  ref = null;
61
71
  }
62
72
  }
63
- const snap = { id, label: safeLabel, createdAt, commit, ref };
73
+ const snap = { id, label: safeLabel, sessionId: sid, createdAt, commit, ref, seq: ++this.seq };
64
74
  this.snapshots.push(snap);
65
- this._trim();
75
+ this._trimFor(sid);
66
76
  return snap;
67
77
  }
68
78
 
69
- listSnapshots() {
70
- return [...this.snapshots].sort((a, b) => b.createdAt - a.createdAt);
79
+ listSnapshots(sessionId) {
80
+ const sid = sessionId != null ? String(sessionId).trim() : undefined;
81
+ const list = sid !== undefined ? this.snapshots.filter(s => String(s.sessionId) === sid) : [...this.snapshots];
82
+ return [...list].sort((a, b) => b.createdAt - a.createdAt || b.seq - a.seq);
71
83
  }
72
84
 
73
85
  getSnapshot(id) {
@@ -91,7 +103,6 @@ export class ShadowSnapshotEngine {
91
103
  try {
92
104
  await this.exec('git', ['reset', '--hard', snap.commit], this.cwd ? { cwd: this.cwd } : {});
93
105
  } catch (e) {
94
- // fallback to checkout of commit tree
95
106
  await this.exec('git', ['read-tree', snap.commit], this.cwd ? { cwd: this.cwd } : {}).catch(()=>{});
96
107
  await this.exec('git', ['checkout-index', '-a', '-f'], this.cwd ? { cwd: this.cwd } : {}).catch(()=>{});
97
108
  }
@@ -99,6 +110,40 @@ export class ShadowSnapshotEngine {
99
110
  return { rolledBack: true, snapshot: snap };
100
111
  }
101
112
 
113
+ async deleteSnapshot(id, { confirm } = {}) {
114
+ if (confirm !== true) {
115
+ const err = new Error('confirm:true required to delete');
116
+ err.code = 'CONFIRM_REQUIRED';
117
+ throw err;
118
+ }
119
+ const snap = this.getSnapshot(id);
120
+ if (!snap) {
121
+ const err = new Error(`snapshot ${id} not found`);
122
+ err.code = 'NOT_FOUND';
123
+ throw err;
124
+ }
125
+ if (snap.ref) {
126
+ try {
127
+ await this.exec('git', ['update-ref', '-d', snap.ref], this.cwd ? { cwd: this.cwd } : {});
128
+ } catch {}
129
+ }
130
+ const idx = this.snapshots.indexOf(snap);
131
+ if (idx !== -1) this.snapshots.splice(idx, 1);
132
+ return { deleted: true, snapshot: snap };
133
+ }
134
+
135
+ async pruneSnapshots(sessionId, keep = 3) {
136
+ const sid = sessionId != null ? String(sessionId).trim() : undefined;
137
+ const list = this.listSnapshots(sid); // newest first
138
+ const minKeep = Math.max(0, Number(keep) || 3);
139
+ const toRemove = list.slice(minKeep);
140
+ const removed = [];
141
+ for (const snap of toRemove) {
142
+ try { await this.deleteSnapshot(snap.id, { confirm: true }); removed.push(snap.id); } catch {}
143
+ }
144
+ return { removed, kept: list.length - removed.length };
145
+ }
146
+
102
147
  async diff(fromId, toId) {
103
148
  const from = this.getSnapshot(fromId);
104
149
  if (!from) {
@@ -107,7 +152,6 @@ export class ShadowSnapshotEngine {
107
152
  throw err;
108
153
  }
109
154
  let to = toId ? this.getSnapshot(toId) : null;
110
- // if to not given, diff against current HEAD/working tree
111
155
  const inGit = await this._isGitRepo();
112
156
  if (inGit && from.commit) {
113
157
  try {
@@ -116,7 +160,6 @@ export class ShadowSnapshotEngine {
116
160
  const diffStat = String(stdout ?? '').trim();
117
161
  if (diffStat) return { from: fromId, to: toId || 'HEAD', diff: diffStat };
118
162
  } catch {}
119
- // fallback to name-only
120
163
  try {
121
164
  const range = to?.commit ? `${from.commit}..${to.commit}` : from.commit;
122
165
  const { stdout } = await this.exec('git', ['diff', '--name-only', range], this.cwd ? { cwd: this.cwd } : {});
@@ -129,6 +172,11 @@ export class ShadowSnapshotEngine {
129
172
 
130
173
  setMax(n) {
131
174
  this.maxSnapshots = Math.max(1, Number(n) || 20);
132
- this._trim();
175
+ // trim all sessions
176
+ const sessions = [...new Set(this.snapshots.map(s => s.sessionId))];
177
+ for (const sid of sessions) this._trimFor(sid);
178
+ if (sessions.length === 0) {
179
+ while (this.snapshots.length > this.maxSnapshots) this.snapshots.shift();
180
+ }
133
181
  }
134
182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-time-machine",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "DSH plugin for smart checkpoints, workspace safety guards, and instant rollback",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",