@goodandready/dsh-time-machine 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GooDAnDReaDY
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # dsh-time-machine
2
+
3
+ DSH plugin for smart checkpoints, workspace safety guards, and instant rollback for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
4
+
5
+ ## Tools
6
+ - `time_machine_checkpoint_create`
7
+ - `time_machine_checkpoint_list`
8
+ - `time_machine_checkpoint_rollback`
9
+ - `time_machine_diff`
10
+
11
+ ## Settings
12
+ Located in **Settings -> Plugins -> Time Machine & Checkpoints**:
13
+ - `autoSnapshotEnabled`: Take automatic checkpoints before file changes (default: `true`)
14
+ - `maxSnapshots`: Maximum snapshots in session history (default: `20`)
15
+ - `autoHealPrompt`: Prompt recovery on command failure (default: `true`)
16
+
17
+ ## Verification
18
+ ```bash
19
+ npm test
20
+ ```
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-time-machine
3
+ name: '@goodandready/dsh-time-machine'
package/lib/client.js ADDED
@@ -0,0 +1,388 @@
1
+ window.__ModuleLoader__.load({
2
+ id: '@goodandready/dsh-time-machine',
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ const React = require('react');
6
+ const NS = '@goodandready/dsh-time-machine';
7
+
8
+ const CSS = '.tm-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;margin:0;padding:0;overflow:hidden;box-sizing:border-box}'
9
+ + '.tm-head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}'
10
+ + '.tm-head:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.05))}'
11
+ + '.tm-head:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}'
12
+ + '.tm-head-text{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0}'
13
+ + '.tm-title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}'
14
+ + '.tm-sub{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.45}'
15
+ + '.tm-chev{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s}'
16
+ + '.tm-chev-open{transform:rotate(180deg)}'
17
+ + '.tm-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px;display:flex;flex-direction:column;gap:12px}'
18
+ + '.tm-field{display:flex;flex-direction:column;gap:6px;padding:12px 0}'
19
+ + '.tm-label{color:var(--dsw-alias-label-secondary);font-size:12px}'
20
+ + '.tm-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;box-sizing:border-box}'
21
+ + '.tm-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px}'
22
+ + '.tm-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:5px 14px;font-size:13px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}'
23
+ + '.tm-save:disabled{opacity:.45;cursor:default}'
24
+ + '.tm-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:5px 10px;font-size:12px;background:transparent;color:var(--dsw-alias-label-primary)}'
25
+ + '.tm-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary)}'
26
+ + '.tm-list{display:flex;flex-direction:column;gap:8px}'
27
+ + '.tm-row{display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-1,transparent)}'
28
+ + '.tm-row-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}'
29
+ + '.tm-row-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);overflow-wrap:anywhere}'
30
+ + '.tm-row-meta{font-size:11px;color:var(--dsw-alias-label-secondary)}'
31
+ + '.tm-modal{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:9999;padding:16px}'
32
+ + '.tm-modal-box{max-width:760px;width:100%;max-height:80vh;overflow:auto;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:16px;white-space:pre-wrap;font-family:monospace;font-size:12px}'
33
+ + '.tm-tab{padding:12px;display:flex;flex-direction:column;gap:12px;height:100%;box-sizing:border-box;overflow:auto}';
34
+
35
+ function ensureStyles() {
36
+ if (typeof document === 'undefined' || !document.head) return;
37
+ const id = 'dsh-time-machine/settings.module.css';
38
+ if (document.querySelector('style[data-plugin-css="' + id + '"]')) return;
39
+ const tag = document.createElement('style');
40
+ tag.textContent = CSS;
41
+ tag.setAttribute('data-plugin', 'dsh-time-machine');
42
+ tag.setAttribute('data-plugin-css', id);
43
+ document.head.appendChild(tag);
44
+ }
45
+
46
+ const en = {
47
+ title: 'Time Machine & Checkpoints',
48
+ sub: 'Shadow snapshots, diff and instant rollback for agent safety.',
49
+ loading: 'Loading…',
50
+ create: 'Create checkpoint',
51
+ labelPh: 'Checkpoint label',
52
+ list: 'Timeline',
53
+ empty: 'No checkpoints yet.',
54
+ diff: 'Diff',
55
+ rollback: 'Rollback',
56
+ confirmRollback: 'Rollback to this checkpoint? This will reset the workspace.',
57
+ close: 'Close',
58
+ save: 'Save',
59
+ saved: 'Saved',
60
+ autoSnapshot: 'Auto snapshot before file changes',
61
+ maxSnapshots: 'Max snapshots',
62
+ autoHeal: 'Prompt recovery on failure',
63
+ unavailable: 'Settings unavailable',
64
+ tabTitle: 'Time Machine',
65
+ };
66
+ const ru = {
67
+ title: 'Машина времени и чекпоинты',
68
+ sub: 'Теневые снапшоты, diff и мгновенный откат для безопасности агента.',
69
+ loading: 'Загрузка…',
70
+ create: 'Создать чекпоинт',
71
+ labelPh: 'Метка чекпоинта',
72
+ list: 'Лента',
73
+ empty: 'Чекпоинтов пока нет.',
74
+ diff: 'Diff',
75
+ rollback: 'Откатить',
76
+ confirmRollback: 'Откатиться к этому чекпоинту? Рабочая копия будет сброшена.',
77
+ close: 'Закрыть',
78
+ save: 'Сохранить',
79
+ saved: 'Сохранено',
80
+ autoSnapshot: 'Авто-снапшот перед изменениями',
81
+ maxSnapshots: 'Макс. снапшотов',
82
+ autoHeal: 'Предлагать восстановление при ошибке',
83
+ unavailable: 'Настройки недоступны',
84
+ tabTitle: 'Машина времени',
85
+ };
86
+
87
+ let ChevronIcon = null;
88
+ try {
89
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives');
90
+ ChevronIcon = primitives && primitives.IconChevronDownOutline14;
91
+ } catch (e) { ChevronIcon = null; }
92
+ function FallbackChevron(props) {
93
+ return React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': 'true', ...props },
94
+ React.createElement('path', { d: 'M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5', fill: 'currentColor' }));
95
+ }
96
+ const Chevron = ChevronIcon || FallbackChevron;
97
+
98
+ function useLocale(ctx) {
99
+ if (!ctx || !ctx.locale) return 'en';
100
+ const lang = React.useSyncExternalStore(
101
+ React.useMemo(() => (cb) => ctx.locale.subscribe(cb) ?? (() => {}), [ctx]),
102
+ React.useCallback(() => ctx.locale.getSnapshot().active ?? 'en', [ctx])
103
+ );
104
+ return lang;
105
+ }
106
+
107
+ function Timeline({ t }) {
108
+ const [snapshots, setSnapshots] = React.useState([]);
109
+ const [loading, setLoading] = React.useState(false);
110
+ const [label, setLabel] = React.useState('');
111
+ const [diffText, setDiffText] = React.useState(null);
112
+ const [busy, setBusy] = React.useState(false);
113
+ const [err, setErr] = React.useState('');
114
+
115
+ const fetchList = React.useCallback(() => {
116
+ setLoading(true); setErr('');
117
+ fetch('/dsh-time-machine/snapshots').then(r => r.json()).then(j => {
118
+ if (j.success) setSnapshots(j.snapshots || []);
119
+ else setErr(j.error || 'load failed');
120
+ }).catch(e => setErr(String(e.message || e))).finally(() => setLoading(false));
121
+ }, []);
122
+
123
+ React.useEffect(() => { fetchList(); }, [fetchList]);
124
+
125
+ const onCreate = () => {
126
+ setBusy(true); setErr('');
127
+ fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) })
128
+ .then(r => r.json()).then(j => {
129
+ if (!j.success) throw new Error(j.error || 'create failed');
130
+ setLabel(''); fetchList();
131
+ }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
132
+ };
133
+ const onDiff = (id) => {
134
+ setBusy(true); setErr('');
135
+ fetch('/dsh-time-machine/diff?from=' + encodeURIComponent(id)).then(r => r.json()).then(j => {
136
+ if (!j.success) throw new Error(j.error || 'diff failed');
137
+ setDiffText(j.diff || '');
138
+ }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
139
+ };
140
+ const onRollback = (id) => {
141
+ if (!window.confirm(t.confirmRollback)) return;
142
+ setBusy(true); setErr('');
143
+ fetch('/dsh-time-machine/rollback', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, confirm: true }) })
144
+ .then(r => r.json()).then(j => {
145
+ if (!j.success) throw new Error(j.error || 'rollback failed');
146
+ fetchList();
147
+ }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
148
+ };
149
+
150
+ return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } },
151
+ React.createElement('div', { style: { display: 'flex', gap: 8 } },
152
+ React.createElement('input', { className: 'tm-input', style: { flex: 1 }, placeholder: t.labelPh, value: label, onChange: (e) => setLabel(e.target.value) }),
153
+ React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: onCreate }, t.create)
154
+ ),
155
+ loading ? React.createElement('div', { className: 'tm-label' }, t.loading) : null,
156
+ err ? React.createElement('div', { className: 'tm-label', style: { color: 'var(--dsw-alias-state-error-primary)' } }, err) : null,
157
+ snapshots.length === 0 && !loading ? React.createElement('div', { className: 'tm-label' }, t.empty) : null,
158
+ React.createElement('div', { className: 'tm-list' },
159
+ snapshots.map(s => React.createElement('div', { key: s.id, className: 'tm-row' },
160
+ React.createElement('span', { className: 'tm-row-main' },
161
+ React.createElement('span', { className: 'tm-row-title' }, s.label + ' · ' + s.id),
162
+ React.createElement('span', { className: 'tm-row-meta' }, new Date(s.createdAt).toLocaleString() + (s.commit ? ' · ' + s.commit.slice(0,7) : ''))
163
+ ),
164
+ 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)
166
+ ))
167
+ ),
168
+ diffText !== null ? React.createElement('div', { className: 'tm-modal', onClick: () => setDiffText(null) },
169
+ React.createElement('div', { className: 'tm-modal-box', onClick: (e) => e.stopPropagation() },
170
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 } },
171
+ React.createElement('strong', null, 'diff'),
172
+ React.createElement('button', { className: 'tm-btn', onClick: () => setDiffText(null) }, t.close)
173
+ ),
174
+ React.createElement('pre', { style: { margin: 0, whiteSpace: 'pre-wrap' } }, diffText)
175
+ )
176
+ ) : null
177
+ );
178
+ }
179
+
180
+ function PluginCard(props) {
181
+ const ctx = props.ctx;
182
+ const [expanded, setExpanded] = React.useState(false);
183
+ const lang = useLocale(ctx);
184
+ const t = lang === 'ru' ? ru : en;
185
+ const [snapshots, setSnapshots] = React.useState([]);
186
+ const [loading, setLoading] = React.useState(false);
187
+ const [label, setLabel] = React.useState('');
188
+ const [diffText, setDiffText] = React.useState(null);
189
+ const [busy, setBusy] = React.useState(false);
190
+ const [err, setErr] = React.useState('');
191
+ const [settingsStatus, setSettingsStatus] = React.useState('loading');
192
+ const [draft, setDraft] = React.useState({ autoSnapshotEnabled: true, maxSnapshots: 20, autoHealPrompt: true });
193
+ const [saving, setSaving] = React.useState(false);
194
+
195
+ const scope = React.useMemo(() => {
196
+ try { return ctx.settingsScope ? ctx.settingsScope.bind({ namespace: NS }) : null; } catch { return null; }
197
+ }, [ctx]);
198
+
199
+ React.useEffect(() => { ensureStyles(); }, []);
200
+
201
+ React.useEffect(() => {
202
+ if (!scope) { setSettingsStatus('unavailable'); return; }
203
+ let alive = true;
204
+ const sync = () => {
205
+ try {
206
+ const snap = scope.getSnapshot ? scope.getSnapshot() : null;
207
+ if (!snap) { if (alive) setSettingsStatus('loading'); return; }
208
+ if (snap.status === 'loading') { if (alive) setSettingsStatus('loading'); return; }
209
+ if (snap.status === 'unavailable') { if (alive) setSettingsStatus('unavailable'); return; }
210
+ const val = snap.value || snap.data || {};
211
+ if (alive) {
212
+ setDraft({
213
+ autoSnapshotEnabled: val.autoSnapshotEnabled ?? true,
214
+ maxSnapshots: val.maxSnapshots ?? 20,
215
+ autoHealPrompt: val.autoHealPrompt ?? true,
216
+ });
217
+ setSettingsStatus('ready');
218
+ }
219
+ } catch { if (alive) setSettingsStatus('unavailable'); }
220
+ };
221
+ sync();
222
+ const unsub = scope.subscribe ? scope.subscribe(sync) : () => {};
223
+ return () => { alive = false; try { unsub(); } catch {} };
224
+ }, [scope]);
225
+
226
+ const fetchList = React.useCallback(() => {
227
+ setLoading(true); setErr('');
228
+ fetch('/dsh-time-machine/snapshots').then(r => r.json()).then(j => {
229
+ if (j.success) setSnapshots(j.snapshots || []);
230
+ else setErr(j.error || 'load failed');
231
+ }).catch(e => setErr(String(e.message || e))).finally(() => setLoading(false));
232
+ }, []);
233
+
234
+ React.useEffect(() => { if (expanded) fetchList(); }, [expanded, fetchList]);
235
+
236
+ const onCreate = () => {
237
+ setBusy(true); setErr('');
238
+ fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) })
239
+ .then(r => r.json()).then(j => {
240
+ if (!j.success) throw new Error(j.error || 'create failed');
241
+ setLabel(''); fetchList();
242
+ }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
243
+ };
244
+ const onDiff = (id) => {
245
+ setBusy(true); setErr('');
246
+ fetch('/dsh-time-machine/diff?from=' + encodeURIComponent(id)).then(r => r.json()).then(j => {
247
+ if (!j.success) throw new Error(j.error || 'diff failed');
248
+ setDiffText(j.diff || '');
249
+ }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
250
+ };
251
+ const onRollback = (id) => {
252
+ if (!window.confirm(t.confirmRollback)) return;
253
+ setBusy(true); setErr('');
254
+ fetch('/dsh-time-machine/rollback', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, confirm: true }) })
255
+ .then(r => r.json()).then(j => {
256
+ if (!j.success) throw new Error(j.error || 'rollback failed');
257
+ fetchList();
258
+ }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
259
+ };
260
+ const onSave = async () => {
261
+ if (!scope) return;
262
+ setSaving(true); setErr('');
263
+ try {
264
+ const keys = ['autoSnapshotEnabled', 'maxSnapshots', 'autoHealPrompt'];
265
+ const failures = [];
266
+ for (const k of keys) {
267
+ try { await scope.set(k, draft[k]); } catch (e) { failures.push(k + ': ' + String(e.message || e)); }
268
+ }
269
+ if (failures.length) setErr(failures.join('; '));
270
+ } catch (e) { setErr(String(e.message || e)); }
271
+ finally { setSaving(false); }
272
+ };
273
+
274
+ const writable = settingsStatus === 'ready' || settingsStatus === 'unavailable';
275
+
276
+ return React.createElement('li', { className: 'tm-card' },
277
+ React.createElement('button', { type: 'button', className: 'tm-head', 'aria-expanded': expanded, onClick: () => setExpanded(!expanded) },
278
+ React.createElement('span', { className: 'tm-head-text' },
279
+ React.createElement('span', { className: 'tm-title' }, t.title),
280
+ React.createElement('span', { className: 'tm-sub' }, t.sub)
281
+ ),
282
+ React.createElement(Chevron, { className: 'tm-chev' + (expanded ? ' tm-chev-open' : '') })
283
+ ),
284
+ expanded ? React.createElement('div', { className: 'tm-body' },
285
+ React.createElement('div', null,
286
+ settingsStatus === 'loading' ? React.createElement('div', { className: 'tm-label' }, t.loading) : null,
287
+ settingsStatus === 'unavailable' ? React.createElement('div', { className: 'tm-label', style: { color: 'var(--dsw-alias-state-warning-primary)' } }, t.unavailable) : null,
288
+ React.createElement('div', { className: 'tm-field' },
289
+ React.createElement('label', { className: 'tm-label' },
290
+ React.createElement('input', { type: 'checkbox', checked: !!draft.autoSnapshotEnabled, disabled: !writable, onChange: (e) => setDraft(d => ({ ...d, autoSnapshotEnabled: e.target.checked })) }), ' ' + t.autoSnapshot
291
+ ),
292
+ React.createElement('label', { className: 'tm-label' }, t.maxSnapshots,
293
+ React.createElement('input', { className: 'tm-input', type: 'number', value: draft.maxSnapshots, disabled: !writable, onChange: (e) => setDraft(d => ({ ...d, maxSnapshots: Number(e.target.value) || 20 })) })
294
+ ),
295
+ React.createElement('label', { className: 'tm-label' },
296
+ React.createElement('input', { type: 'checkbox', checked: !!draft.autoHealPrompt, disabled: !writable, onChange: (e) => setDraft(d => ({ ...d, autoHealPrompt: e.target.checked })) }), ' ' + t.autoHeal
297
+ )
298
+ ),
299
+ React.createElement('div', { className: 'tm-foot' },
300
+ React.createElement('button', { className: 'tm-save', disabled: !writable || saving, onClick: onSave }, saving ? t.loading : t.save)
301
+ )
302
+ ),
303
+ React.createElement('div', { className: 'tm-field' },
304
+ React.createElement('div', { style: { display: 'flex', gap: 8 } },
305
+ React.createElement('input', { className: 'tm-input', style: { flex: 1 }, placeholder: t.labelPh, value: label, onChange: (e) => setLabel(e.target.value) }),
306
+ React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: onCreate }, t.create)
307
+ )
308
+ ),
309
+ React.createElement('div', { className: 'tm-field' },
310
+ React.createElement('div', { className: 'tm-label' }, t.list),
311
+ loading ? React.createElement('div', { className: 'tm-label' }, t.loading) : null,
312
+ err ? React.createElement('div', { className: 'tm-label', style: { color: 'var(--dsw-alias-state-error-primary)' } }, err) : null,
313
+ snapshots.length === 0 && !loading ? React.createElement('div', { className: 'tm-label' }, t.empty) : null,
314
+ React.createElement('div', { className: 'tm-list' },
315
+ snapshots.map(s => React.createElement('div', { key: s.id, className: 'tm-row' },
316
+ React.createElement('span', { className: 'tm-row-main' },
317
+ React.createElement('span', { className: 'tm-row-title' }, s.label + ' · ' + s.id),
318
+ React.createElement('span', { className: 'tm-row-meta' }, new Date(s.createdAt).toLocaleString() + (s.commit ? ' · ' + s.commit.slice(0,7) : ''))
319
+ ),
320
+ 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)
322
+ ))
323
+ )
324
+ ),
325
+ diffText !== null ? React.createElement('div', { className: 'tm-modal', onClick: () => setDiffText(null) },
326
+ React.createElement('div', { className: 'tm-modal-box', onClick: (e) => e.stopPropagation() },
327
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 } },
328
+ React.createElement('strong', null, 'diff'),
329
+ React.createElement('button', { className: 'tm-btn', onClick: () => setDiffText(null) }, t.close)
330
+ ),
331
+ React.createElement('pre', { style: { margin: 0, whiteSpace: 'pre-wrap' } }, diffText)
332
+ )
333
+ ) : null
334
+ ) : null
335
+ );
336
+ }
337
+
338
+ function TimeMachineTab(props) {
339
+ const ctx = props.ctx;
340
+ const lang = useLocale(ctx);
341
+ const t = lang === 'ru' ? ru : en;
342
+ return React.createElement('div', { className: 'tm-tab' },
343
+ React.createElement('div', { style: { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, t.tabTitle),
344
+ React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: 4 } }, t.sub),
345
+ React.createElement(Timeline, { t })
346
+ );
347
+ }
348
+
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
+ module.exports.inject = ['slots', 'locale'];
371
+ module.exports.apply = function apply(ctx) {
372
+ ensureStyles();
373
+ try { ctx.locale && ctx.locale.register && ctx.locale.register(NS, { en, ru }); } catch {}
374
+ const t = (ctx.locale && ctx.locale.bind) ? ctx.locale.bind(NS) : (k) => k;
375
+ try {
376
+ ctx.slots.register({ name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) }, PluginCard);
377
+ } catch (e) {
378
+ try {
379
+ ctx.slots.register({ name: 'settings.section', id: NS, order: 30, label: () => t('title'), inject: () => ({ ctx }) }, PluginCard);
380
+ } catch {}
381
+ }
382
+ // betterSidebar tab — immediate + inject fallback
383
+ registerBetterSidebar(ctx);
384
+ try { ctx.inject(['betterSidebar'], () => { registerBetterSidebar(ctx); }); } catch {}
385
+ };
386
+ return module.exports;
387
+ },
388
+ });
package/lib/index.js ADDED
@@ -0,0 +1,171 @@
1
+ import Schema from '@deepseek-ai/schemastery';
2
+ import { ShadowSnapshotEngine } from './snapshot.js';
3
+
4
+ export const name = '@goodandready/dsh-time-machine';
5
+ export const inject = ['tools', 'settings', 'webServer'];
6
+
7
+ export const Config = Schema.object({
8
+ autoSnapshotEnabled: Schema.boolean().default(true).description('Take automatic checkpoints before file changes'),
9
+ maxSnapshots: Schema.number().default(20).description('Maximum snapshots in session history'),
10
+ autoHealPrompt: Schema.boolean().default(true).description('Prompt recovery on command failure'),
11
+ });
12
+
13
+ const NS = '@goodandready/dsh-time-machine';
14
+
15
+ export function apply(ctx, config) {
16
+ let getConfig = () => config;
17
+ let engine = new ShadowSnapshotEngine({ maxSnapshots: config?.maxSnapshots ?? 20 });
18
+
19
+ ctx.inject(['settings'], (sctx) => {
20
+ const scope = sctx.settings.register(NS, Config, { base: config });
21
+ getConfig = () => scope.get() ?? config;
22
+ const syncMax = () => engine.setMax(getConfig().maxSnapshots ?? 20);
23
+ syncMax();
24
+ const stop = scope.watch ? scope.watch(syncMax) : () => {};
25
+ sctx.effect(() => () => { try { stop(); } catch {} }, 'dsh-time-machine: settings watch');
26
+ });
27
+
28
+ // tools
29
+ const registerTools = (tctx) => {
30
+ tctx.tools.register({
31
+ name: 'time_machine_checkpoint_create',
32
+ description: 'Create a workspace checkpoint (shadow git snapshot) before risky changes. Returns id and label.',
33
+ parameters: {
34
+ type: 'object',
35
+ properties: {
36
+ label: { type: 'string', description: 'Human label for checkpoint' },
37
+ },
38
+ },
39
+ execute: async ({ label } = {}) => {
40
+ const snap = await engine.createSnapshot(label);
41
+ return { success: true, snapshot: snap };
42
+ },
43
+ });
44
+
45
+ tctx.tools.register({
46
+ 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 };
52
+ },
53
+ });
54
+
55
+ tctx.tools.register({
56
+ name: 'time_machine_checkpoint_rollback',
57
+ description: 'Rollback workspace to a checkpoint. Requires confirm:true.',
58
+ parameters: {
59
+ type: 'object',
60
+ properties: {
61
+ id: { type: 'string', description: 'Snapshot id to rollback to' },
62
+ confirm: { type: 'boolean', description: 'Must be true to confirm destructive rollback' },
63
+ },
64
+ required: ['id', 'confirm'],
65
+ },
66
+ execute: async ({ id, confirm } = {}) => {
67
+ const res = await engine.rollbackSnapshot(String(id ?? ''), { confirm });
68
+ return { success: true, ...res };
69
+ },
70
+ });
71
+
72
+ tctx.tools.register({
73
+ name: 'time_machine_diff',
74
+ description: 'Show diff between checkpoint and current or another checkpoint.',
75
+ parameters: {
76
+ type: 'object',
77
+ properties: {
78
+ from: { type: 'string', description: 'Source snapshot id' },
79
+ to: { type: 'string', description: 'Target snapshot id (default HEAD)' },
80
+ },
81
+ required: ['from'],
82
+ },
83
+ execute: async ({ from, to } = {}) => {
84
+ const res = await engine.diff(String(from ?? ''), to ? String(to) : undefined);
85
+ return { success: true, ...res };
86
+ },
87
+ });
88
+ };
89
+
90
+ try {
91
+ if (ctx.tools) registerTools(ctx);
92
+ else ctx.inject(['tools'], (tctx) => registerTools(tctx));
93
+ } catch {}
94
+
95
+ // web routes for UI
96
+ ctx.effect(() => {
97
+ const disposals = [];
98
+ try {
99
+ disposals.push(ctx.webServer.register({
100
+ kind: 'exact',
101
+ path: '/dsh-time-machine/snapshots',
102
+ handler: (req, res) => {
103
+ res.setHeader('content-type', 'application/json');
104
+ res.end(JSON.stringify({ success: true, snapshots: engine.listSnapshots() }));
105
+ },
106
+ }));
107
+ disposals.push(ctx.webServer.register({
108
+ kind: 'exact',
109
+ path: '/dsh-time-machine/diff',
110
+ handler: async (req, res) => {
111
+ const url = new URL(req.url, 'http://localhost');
112
+ const from = url.searchParams.get('from') || '';
113
+ const to = url.searchParams.get('to') || undefined;
114
+ try {
115
+ const out = await engine.diff(String(from), to ? String(to) : undefined);
116
+ res.setHeader('content-type', 'application/json');
117
+ res.end(JSON.stringify({ success: true, ...out }));
118
+ } catch (e) {
119
+ res.statusCode = 400;
120
+ res.setHeader('content-type', 'application/json');
121
+ res.end(JSON.stringify({ success: false, error: String(e.message || e) }));
122
+ }
123
+ },
124
+ }));
125
+ disposals.push(ctx.webServer.register({
126
+ kind: 'exact',
127
+ path: '/dsh-time-machine/create',
128
+ handler: async (req, res) => {
129
+ let body = '';
130
+ req.on('data', (c) => body += c);
131
+ req.on('end', async () => {
132
+ try {
133
+ const parsed = body ? JSON.parse(body) : {};
134
+ const snap = await engine.createSnapshot(parsed.label);
135
+ res.setHeader('content-type', 'application/json');
136
+ res.end(JSON.stringify({ success: true, snapshot: snap }));
137
+ } catch (e) {
138
+ res.statusCode = 400;
139
+ res.setHeader('content-type', 'application/json');
140
+ res.end(JSON.stringify({ success: false, error: String(e.message || e) }));
141
+ }
142
+ });
143
+ },
144
+ }));
145
+ disposals.push(ctx.webServer.register({
146
+ kind: 'exact',
147
+ path: '/dsh-time-machine/rollback',
148
+ handler: async (req, res) => {
149
+ let body = '';
150
+ req.on('data', (c) => body += c);
151
+ req.on('end', async () => {
152
+ try {
153
+ const parsed = body ? JSON.parse(body) : {};
154
+ const out = await engine.rollbackSnapshot(String(parsed.id || ''), { confirm: parsed.confirm });
155
+ res.setHeader('content-type', 'application/json');
156
+ res.end(JSON.stringify({ success: true, ...out }));
157
+ } catch (e) {
158
+ res.statusCode = 400;
159
+ res.setHeader('content-type', 'application/json');
160
+ res.end(JSON.stringify({ success: false, error: String(e.message || e), code: e.code || '' }));
161
+ }
162
+ });
163
+ },
164
+ }));
165
+ } catch {}
166
+ return () => { for (const d of disposals) try { typeof d === 'function' && d(); } catch {} };
167
+ }, 'dsh-time-machine: web routes');
168
+
169
+ // expose engine for tests
170
+ ctx.provide?.('timeMachineEngine', engine);
171
+ }
@@ -0,0 +1,134 @@
1
+ import crypto from 'node:crypto';
2
+ import { execFile as execFileCb } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+
5
+ const execFile = promisify(execFileCb);
6
+
7
+ async function defaultExec(cmd, args, opts = {}) {
8
+ try {
9
+ const { stdout } = await execFile(cmd, args, { encoding: 'utf8', ...opts });
10
+ return { stdout: String(stdout ?? ''), stderr: '' };
11
+ } catch (e) {
12
+ const err = e;
13
+ err.stdout = String(err.stdout ?? '');
14
+ err.stderr = String(err.stderr ?? err.message ?? '');
15
+ throw err;
16
+ }
17
+ }
18
+
19
+ // ponytail: in-memory engine, git shadow refs if repo present; O(n) scan, global lock
20
+ export class ShadowSnapshotEngine {
21
+ constructor({ exec = defaultExec, maxSnapshots = 20, cwd } = {}) {
22
+ this.exec = exec;
23
+ this.maxSnapshots = maxSnapshots;
24
+ this.cwd = cwd;
25
+ this.snapshots = []; // newest last, list returns newest first
26
+ }
27
+
28
+ _now() { return Date.now(); }
29
+
30
+ _trim() {
31
+ while (this.snapshots.length > this.maxSnapshots) this.snapshots.shift();
32
+ }
33
+
34
+ async _isGitRepo() {
35
+ try {
36
+ await this.exec('git', ['rev-parse', '--is-inside-work-tree'], this.cwd ? { cwd: this.cwd } : {});
37
+ return true;
38
+ } catch { return false; }
39
+ }
40
+
41
+ async createSnapshot(label = '') {
42
+ const id = crypto.randomUUID().slice(0, 8);
43
+ const createdAt = this._now();
44
+ const safeLabel = String(label ?? '').trim() || `checkpoint-${id}`;
45
+ let commit = null;
46
+ let ref = null;
47
+ const inGit = await this._isGitRepo();
48
+ if (inGit) {
49
+ try {
50
+ // stage working tree without committing to main branch
51
+ await this.exec('git', ['add', '-A'], this.cwd ? { cwd: this.cwd } : {});
52
+ const { stdout: tree } = await this.exec('git', ['write-tree'], this.cwd ? { cwd: this.cwd } : {});
53
+ const treeHash = String(tree).trim();
54
+ const { stdout: commitHash } = await this.exec('git', ['commit-tree', treeHash, '-m', safeLabel], this.cwd ? { cwd: this.cwd } : {});
55
+ commit = String(commitHash).trim();
56
+ ref = `refs/dsh-time-machine/${id}`;
57
+ await this.exec('git', ['update-ref', ref, commit], this.cwd ? { cwd: this.cwd } : {});
58
+ } catch {
59
+ commit = null;
60
+ ref = null;
61
+ }
62
+ }
63
+ const snap = { id, label: safeLabel, createdAt, commit, ref };
64
+ this.snapshots.push(snap);
65
+ this._trim();
66
+ return snap;
67
+ }
68
+
69
+ listSnapshots() {
70
+ return [...this.snapshots].sort((a, b) => b.createdAt - a.createdAt);
71
+ }
72
+
73
+ getSnapshot(id) {
74
+ return this.snapshots.find(s => s.id === id) || null;
75
+ }
76
+
77
+ async rollbackSnapshot(id, { confirm } = {}) {
78
+ if (confirm !== true) {
79
+ const err = new Error('confirm:true required to rollback');
80
+ err.code = 'CONFIRM_REQUIRED';
81
+ throw err;
82
+ }
83
+ const snap = this.getSnapshot(id);
84
+ if (!snap) {
85
+ const err = new Error(`snapshot ${id} not found`);
86
+ err.code = 'NOT_FOUND';
87
+ throw err;
88
+ }
89
+ const inGit = await this._isGitRepo();
90
+ if (inGit && snap.commit) {
91
+ try {
92
+ await this.exec('git', ['reset', '--hard', snap.commit], this.cwd ? { cwd: this.cwd } : {});
93
+ } catch (e) {
94
+ // fallback to checkout of commit tree
95
+ await this.exec('git', ['read-tree', snap.commit], this.cwd ? { cwd: this.cwd } : {}).catch(()=>{});
96
+ await this.exec('git', ['checkout-index', '-a', '-f'], this.cwd ? { cwd: this.cwd } : {}).catch(()=>{});
97
+ }
98
+ }
99
+ return { rolledBack: true, snapshot: snap };
100
+ }
101
+
102
+ async diff(fromId, toId) {
103
+ const from = this.getSnapshot(fromId);
104
+ if (!from) {
105
+ const err = new Error(`snapshot ${fromId} not found`);
106
+ err.code = 'NOT_FOUND';
107
+ throw err;
108
+ }
109
+ let to = toId ? this.getSnapshot(toId) : null;
110
+ // if to not given, diff against current HEAD/working tree
111
+ const inGit = await this._isGitRepo();
112
+ if (inGit && from.commit) {
113
+ try {
114
+ const range = to?.commit ? `${from.commit}..${to.commit}` : `${from.commit}..HEAD`;
115
+ const { stdout } = await this.exec('git', ['diff', '--stat', range], this.cwd ? { cwd: this.cwd } : {});
116
+ const diffStat = String(stdout ?? '').trim();
117
+ if (diffStat) return { from: fromId, to: toId || 'HEAD', diff: diffStat };
118
+ } catch {}
119
+ // fallback to name-only
120
+ try {
121
+ const range = to?.commit ? `${from.commit}..${to.commit}` : from.commit;
122
+ const { stdout } = await this.exec('git', ['diff', '--name-only', range], this.cwd ? { cwd: this.cwd } : {});
123
+ return { from: fromId, to: toId || 'HEAD', diff: String(stdout ?? '').trim() || '(no changes)' };
124
+ } catch {}
125
+ }
126
+ const toLabel = to ? to.label : 'current';
127
+ return { from: fromId, to: toId || 'HEAD', diff: `diff ${from.label} -> ${toLabel} (no git repo)` };
128
+ }
129
+
130
+ setMax(n) {
131
+ this.maxSnapshots = Math.max(1, Number(n) || 20);
132
+ this._trim();
133
+ }
134
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@goodandready/dsh-time-machine",
3
+ "version": "0.1.1",
4
+ "description": "DSH plugin for smart checkpoints, workspace safety guards, and instant rollback",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "lib/",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "test": "node --test test/*.test.mjs"
21
+ },
22
+ "keywords": [
23
+ "dsh",
24
+ "dsh-plugin",
25
+ "checkpoint",
26
+ "rollback",
27
+ "safety",
28
+ "git-snapshot"
29
+ ],
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/GooDAnDReaDY/dsh-time-machine.git"
33
+ },
34
+ "homepage": "https://github.com/GooDAnDReaDY/dsh-time-machine#readme",
35
+ "bugs": {
36
+ "url": "https://github.com/GooDAnDReaDY/dsh-time-machine/issues"
37
+ },
38
+ "license": "MIT",
39
+ "dsh": {
40
+ "bundle": {
41
+ "patch": "./cordis.patch.yml"
42
+ },
43
+ "client": {
44
+ "platform": "web",
45
+ "inject": [
46
+ "@deepseek-ai/dsh-client-runtime",
47
+ "@deepseek-ai/dsh-client-ui-slots"
48
+ ]
49
+ }
50
+ },
51
+ "peerDependencies": {
52
+ "@deepseek-ai/cordis": "^4.0.1",
53
+ "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
54
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
55
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
56
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
57
+ "@deepseek-ai/schemastery": "^3.18.1"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public"
61
+ }
62
+ }