@goodandready/dsh-time-machine 0.1.3 → 0.1.4

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
@@ -104,7 +104,24 @@ window.__ModuleLoader__.load({
104
104
  return lang;
105
105
  }
106
106
 
107
- function Timeline({ t }) {
107
+ function sessionIdOf(ctx) {
108
+ try {
109
+ if (!ctx) return '';
110
+ if (ctx.scope) {
111
+ if (ctx.scope.session) return String(ctx.scope.session.id || ctx.scope.session.header?.id || '');
112
+ if (typeof ctx.scope.id === 'string') return ctx.scope.id;
113
+ }
114
+ if (ctx.session) return String(ctx.session.id || ctx.session.header?.id || '');
115
+ if (ctx.get && typeof ctx.get === 'function') {
116
+ const s = ctx.get('session');
117
+ if (s) return String(s.id || s.header?.id || '');
118
+ }
119
+ } catch {}
120
+ return '';
121
+ }
122
+
123
+ function Timeline({ t, ctx }) {
124
+ const sid = sessionIdOf(ctx);
108
125
  const [snapshots, setSnapshots] = React.useState([]);
109
126
  const [loading, setLoading] = React.useState(false);
110
127
  const [label, setLabel] = React.useState('');
@@ -114,17 +131,18 @@ window.__ModuleLoader__.load({
114
131
 
115
132
  const fetchList = React.useCallback(() => {
116
133
  setLoading(true); setErr('');
117
- fetch('/dsh-time-machine/snapshots').then(r => r.json()).then(j => {
134
+ const q = sid ? '?sessionId=' + encodeURIComponent(sid) : '';
135
+ fetch('/dsh-time-machine/snapshots' + q).then(r => r.json()).then(j => {
118
136
  if (j.success) setSnapshots(j.snapshots || []);
119
137
  else setErr(j.error || 'load failed');
120
138
  }).catch(e => setErr(String(e.message || e))).finally(() => setLoading(false));
121
- }, []);
139
+ }, [sid]);
122
140
 
123
141
  React.useEffect(() => { fetchList(); }, [fetchList]);
124
142
 
125
143
  const onCreate = () => {
126
144
  setBusy(true); setErr('');
127
- fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) })
145
+ fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label, sessionId: sid }) })
128
146
  .then(r => r.json()).then(j => {
129
147
  if (!j.success) throw new Error(j.error || 'create failed');
130
148
  setLabel(''); fetchList();
@@ -179,6 +197,7 @@ window.__ModuleLoader__.load({
179
197
 
180
198
  function PluginCard(props) {
181
199
  const ctx = props.ctx;
200
+ const sid = sessionIdOf(ctx);
182
201
  const [expanded, setExpanded] = React.useState(false);
183
202
  const lang = useLocale(ctx);
184
203
  const t = lang === 'ru' ? ru : en;
@@ -225,17 +244,18 @@ window.__ModuleLoader__.load({
225
244
 
226
245
  const fetchList = React.useCallback(() => {
227
246
  setLoading(true); setErr('');
228
- fetch('/dsh-time-machine/snapshots').then(r => r.json()).then(j => {
247
+ const q = sid ? '?sessionId=' + encodeURIComponent(sid) : '';
248
+ fetch('/dsh-time-machine/snapshots' + q).then(r => r.json()).then(j => {
229
249
  if (j.success) setSnapshots(j.snapshots || []);
230
250
  else setErr(j.error || 'load failed');
231
251
  }).catch(e => setErr(String(e.message || e))).finally(() => setLoading(false));
232
- }, []);
252
+ }, [sid]);
233
253
 
234
254
  React.useEffect(() => { if (expanded) fetchList(); }, [expanded, fetchList]);
235
255
 
236
256
  const onCreate = () => {
237
257
  setBusy(true); setErr('');
238
- fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) })
258
+ fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label, sessionId: sid }) })
239
259
  .then(r => r.json()).then(j => {
240
260
  if (!j.success) throw new Error(j.error || 'create failed');
241
261
  setLabel(''); fetchList();
@@ -342,31 +362,10 @@ window.__ModuleLoader__.load({
342
362
  return React.createElement('div', { className: 'tm-tab' },
343
363
  React.createElement('div', { style: { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, t.tabTitle),
344
364
  React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: 4 } }, t.sub),
345
- React.createElement(Timeline, { t })
365
+ React.createElement(Timeline, { t, ctx })
346
366
  );
347
367
  }
348
368
 
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
369
  module.exports.inject = ['slots', 'locale'];
371
370
  module.exports.apply = function apply(ctx) {
372
371
  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,74 @@ 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
+ return () => { for (const off of offs) try { typeof off === 'function' && off(); } catch {} };
70
+ }, 'dsh-time-machine: auto snapshot events');
71
+ }
72
+ } catch {}
73
+
74
+ // tools (session-aware)
29
75
  const registerTools = (tctx) => {
30
76
  tctx.tools.register({
31
77
  name: 'time_machine_checkpoint_create',
32
- description: 'Create a workspace checkpoint (shadow git snapshot) before risky changes. Returns id and label.',
78
+ description: 'Create a workspace checkpoint (shadow git snapshot) before risky changes. Returns id and label. Auto-creates per session if autoSnapshotEnabled.',
33
79
  parameters: {
34
80
  type: 'object',
35
81
  properties: {
36
82
  label: { type: 'string', description: 'Human label for checkpoint' },
83
+ sessionId: { type: 'string', description: 'Session id to scope checkpoint (auto-detected if omitted)' },
37
84
  },
38
85
  },
39
- execute: async ({ label } = {}) => {
40
- const snap = await engine.createSnapshot(label);
86
+ execute: async (args = {}, execution) => {
87
+ const sid = String(args.sessionId || sessionIdOf(execution, tctx) || sessionIdOf(args, tctx) || '').trim();
88
+ const snap = await engine.createSnapshot(args.label, { sessionId: sid });
41
89
  return { success: true, snapshot: snap };
42
90
  },
43
91
  });
44
92
 
45
93
  tctx.tools.register({
46
94
  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 };
95
+ description: 'List recent workspace checkpoints newest first. Filter by sessionId if given.',
96
+ parameters: {
97
+ type: 'object',
98
+ properties: {
99
+ sessionId: { type: 'string', description: 'Filter by session id' },
100
+ },
101
+ },
102
+ execute: async (args = {}, execution) => {
103
+ const sid = args.sessionId != null ? String(args.sessionId).trim() : (sessionIdOf(execution, tctx) ? String(sessionIdOf(execution, tctx)).trim() : undefined);
104
+ const list = sid !== undefined ? engine.listSnapshots(sid) : engine.listSnapshots();
105
+ // if sid undefined and we want all, return all; but if sessionId was auto-detected, filter
106
+ // For backward compat, if no sid provided and engine has sessionIds, return all
107
+ // If sid was auto-detected (execution has session), use it
108
+ const finalList = (args.sessionId == null && sid !== undefined && sid !== '') ? engine.listSnapshots(sid) : (args.sessionId != null ? engine.listSnapshots(String(args.sessionId)) : engine.listSnapshots());
109
+ return { success: true, snapshots: finalList, sessionId: sid || '' };
52
110
  },
53
111
  });
54
112
 
@@ -92,7 +150,7 @@ export function apply(ctx, config) {
92
150
  else ctx.inject(['tools'], (tctx) => registerTools(tctx));
93
151
  } catch {}
94
152
 
95
- // web routes for UI
153
+ // web routes for UI (session-aware via ?sessionId=)
96
154
  ctx.effect(() => {
97
155
  const disposals = [];
98
156
  try {
@@ -100,8 +158,11 @@ export function apply(ctx, config) {
100
158
  kind: 'exact',
101
159
  path: '/dsh-time-machine/snapshots',
102
160
  handler: (req, res) => {
161
+ const url = new URL(req.url, 'http://localhost');
162
+ const sid = url.searchParams.get('sessionId');
163
+ const list = sid != null ? engine.listSnapshots(String(sid)) : engine.listSnapshots();
103
164
  res.setHeader('content-type', 'application/json');
104
- res.end(JSON.stringify({ success: true, snapshots: engine.listSnapshots() }));
165
+ res.end(JSON.stringify({ success: true, snapshots: list }));
105
166
  },
106
167
  }));
107
168
  disposals.push(ctx.webServer.register({
@@ -131,7 +192,8 @@ export function apply(ctx, config) {
131
192
  req.on('end', async () => {
132
193
  try {
133
194
  const parsed = body ? JSON.parse(body) : {};
134
- const snap = await engine.createSnapshot(parsed.label);
195
+ const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '').trim();
196
+ const snap = await engine.createSnapshot(parsed.label, { sessionId: sid });
135
197
  res.setHeader('content-type', 'application/json');
136
198
  res.end(JSON.stringify({ success: true, snapshot: snap }));
137
199
  } catch (e) {
@@ -166,6 +228,5 @@ export function apply(ctx, config) {
166
228
  return () => { for (const d of disposals) try { typeof d === 'function' && d(); } catch {} };
167
229
  }, 'dsh-time-machine: web routes');
168
230
 
169
- // expose engine for tests
170
231
  ctx.provide?.('timeMachineEngine', engine);
171
232
  }
package/lib/snapshot.js CHANGED
@@ -16,19 +16,28 @@ 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
26
  }
27
27
 
28
28
  _now() { return Date.now(); }
29
29
 
30
- _trim() {
31
- while (this.snapshots.length > this.maxSnapshots) this.snapshots.shift();
30
+ _trimFor(sessionId) {
31
+ if (!sessionId) {
32
+ while (this.snapshots.length > this.maxSnapshots) this.snapshots.shift();
33
+ return;
34
+ }
35
+ const perSession = this.snapshots.filter(s => s.sessionId === sessionId);
36
+ while (perSession.length > this.maxSnapshots) {
37
+ const oldest = perSession.shift();
38
+ const idx = this.snapshots.indexOf(oldest);
39
+ if (idx !== -1) this.snapshots.splice(idx, 1);
40
+ }
32
41
  }
33
42
 
34
43
  async _isGitRepo() {
@@ -38,36 +47,38 @@ export class ShadowSnapshotEngine {
38
47
  } catch { return false; }
39
48
  }
40
49
 
41
- async createSnapshot(label = '') {
50
+ async createSnapshot(label = '', { sessionId = '' } = {}) {
42
51
  const id = crypto.randomUUID().slice(0, 8);
43
52
  const createdAt = this._now();
44
53
  const safeLabel = String(label ?? '').trim() || `checkpoint-${id}`;
54
+ const sid = String(sessionId || '').trim();
45
55
  let commit = null;
46
56
  let ref = null;
47
57
  const inGit = await this._isGitRepo();
48
58
  if (inGit) {
49
59
  try {
50
- // stage working tree without committing to main branch
51
60
  await this.exec('git', ['add', '-A'], this.cwd ? { cwd: this.cwd } : {});
52
61
  const { stdout: tree } = await this.exec('git', ['write-tree'], this.cwd ? { cwd: this.cwd } : {});
53
62
  const treeHash = String(tree).trim();
54
63
  const { stdout: commitHash } = await this.exec('git', ['commit-tree', treeHash, '-m', safeLabel], this.cwd ? { cwd: this.cwd } : {});
55
64
  commit = String(commitHash).trim();
56
- ref = `refs/dsh-time-machine/${id}`;
65
+ ref = sid ? `refs/dsh-time-machine/${sid}/${id}` : `refs/dsh-time-machine/${id}`;
57
66
  await this.exec('git', ['update-ref', ref, commit], this.cwd ? { cwd: this.cwd } : {});
58
67
  } catch {
59
68
  commit = null;
60
69
  ref = null;
61
70
  }
62
71
  }
63
- const snap = { id, label: safeLabel, createdAt, commit, ref };
72
+ const snap = { id, label: safeLabel, sessionId: sid, createdAt, commit, ref };
64
73
  this.snapshots.push(snap);
65
- this._trim();
74
+ this._trimFor(sid);
66
75
  return snap;
67
76
  }
68
77
 
69
- listSnapshots() {
70
- return [...this.snapshots].sort((a, b) => b.createdAt - a.createdAt);
78
+ listSnapshots(sessionId) {
79
+ const sid = sessionId != null ? String(sessionId).trim() : undefined;
80
+ const list = sid !== undefined ? this.snapshots.filter(s => String(s.sessionId) === sid) : [...this.snapshots];
81
+ return [...list].sort((a, b) => b.createdAt - a.createdAt);
71
82
  }
72
83
 
73
84
  getSnapshot(id) {
@@ -91,7 +102,6 @@ export class ShadowSnapshotEngine {
91
102
  try {
92
103
  await this.exec('git', ['reset', '--hard', snap.commit], this.cwd ? { cwd: this.cwd } : {});
93
104
  } catch (e) {
94
- // fallback to checkout of commit tree
95
105
  await this.exec('git', ['read-tree', snap.commit], this.cwd ? { cwd: this.cwd } : {}).catch(()=>{});
96
106
  await this.exec('git', ['checkout-index', '-a', '-f'], this.cwd ? { cwd: this.cwd } : {}).catch(()=>{});
97
107
  }
@@ -107,7 +117,6 @@ export class ShadowSnapshotEngine {
107
117
  throw err;
108
118
  }
109
119
  let to = toId ? this.getSnapshot(toId) : null;
110
- // if to not given, diff against current HEAD/working tree
111
120
  const inGit = await this._isGitRepo();
112
121
  if (inGit && from.commit) {
113
122
  try {
@@ -116,7 +125,6 @@ export class ShadowSnapshotEngine {
116
125
  const diffStat = String(stdout ?? '').trim();
117
126
  if (diffStat) return { from: fromId, to: toId || 'HEAD', diff: diffStat };
118
127
  } catch {}
119
- // fallback to name-only
120
128
  try {
121
129
  const range = to?.commit ? `${from.commit}..${to.commit}` : from.commit;
122
130
  const { stdout } = await this.exec('git', ['diff', '--name-only', range], this.cwd ? { cwd: this.cwd } : {});
@@ -129,6 +137,11 @@ export class ShadowSnapshotEngine {
129
137
 
130
138
  setMax(n) {
131
139
  this.maxSnapshots = Math.max(1, Number(n) || 20);
132
- this._trim();
140
+ // trim all sessions
141
+ const sessions = [...new Set(this.snapshots.map(s => s.sessionId))];
142
+ for (const sid of sessions) this._trimFor(sid);
143
+ if (sessions.length === 0) {
144
+ while (this.snapshots.length > this.maxSnapshots) this.snapshots.shift();
145
+ }
133
146
  }
134
147
  }
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.4",
4
4
  "description": "DSH plugin for smart checkpoints, workspace safety guards, and instant rollback",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",