@goodandready/dsh-time-machine 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -60,38 +60,41 @@ graph LR
60
60
 
61
61
  ---
62
62
 
63
- ## Key Capabilities & Studio Features
63
+ ## 🌟 Key Capabilities
64
64
 
65
- ### 1. 🛡️ Non-Intrusive Shadow Git Snapshots (`lib/snapshot.js`)
66
- * Captures complete working tree state, staged files, and untracked assets using shadow git refs;
67
- * Zero pollution of user git commit history, tags, or active branches;
68
- * Retains a rolling history of the last $N$ checkpoints with human-readable labels and timestamps.
65
+ ### 1. 🛡️ Lightweight Shadow Git Snapshots
66
+ * Captures the full working tree, staged changes, and untracked files using isolated Git shadow tree references;
67
+ * Zero interference with user commit history, current active branch, or repository staging area;
68
+ * Maintains a rolling history of the most recent checkpoints with clear timestamps, session scoping, and labels.
69
69
 
70
70
  ### 2. ⏪ Instant Safe Rollback (`time_machine_checkpoint_rollback`)
71
71
  * Restores the entire workspace or specific files to any previous checkpoint in milliseconds;
72
+ * Uses non-destructive checkout-index and clean without moving `HEAD` or rewriting branch history;
72
73
  * Can be triggered programmatically by the agent or interactively by the user in the UI.
73
74
 
74
75
  ### 3. 🔍 Visual Snapshot Diff Inspector (`time_machine_diff`)
75
76
  * Computes file-by-file visual diffs comparing current workspace state against any checkpoint;
76
- * Highlights added, deleted, and modified lines with clean line numbers.
77
+ * Highlights added, deleted, and modified lines with clean line numbers and statistics.
77
78
 
78
- ### 4. 🕒 Interactive Sidebar Timeline (`lib/client.js`)
79
- * Seamlessly integrates into DSH Web UI sidebar;
80
- * Displays a chronological timeline of all session checkpoints with file change counters and 1-click "Restore Checkpoint" and "Inspect Diff" buttons.
79
+ ### 4. 🕒 Interactive Sidebar & Settings Timeline (`lib/client.js`)
80
+ * Seamlessly integrates into DSH Web UI sidebar and settings tab;
81
+ * Displays a chronological timeline of session checkpoints with 1-click "Rollback", "Diff", and "Delete" buttons.
81
82
 
82
83
  ### 5. 🩹 Auto-Heal on Command Failure
83
- * Automatically prompts the user/agent with a 1-click restore proposal when a destructive command exits with an error.
84
+ * Automatically records checkpoints when a tool or command fails, preserving recovery options.
84
85
 
85
86
  ---
86
87
 
87
- ## 🛠️ Agent Tools Reference (4 Tools)
88
+ ## 🛠️ Agent Tools Reference (6 Tools)
88
89
 
89
90
  | Tool Name | Parameters | Description |
90
91
  |---|---|---|
91
- | `time_machine_checkpoint_create` | `label?: string` | Creates a shadow git workspace checkpoint before risky edits or operations |
92
- | `time_machine_checkpoint_list` | *(none)* | Lists all recent workspace checkpoints newest first |
93
- | `time_machine_checkpoint_rollback` | `id: string` | Reverts the entire workspace back to the specified checkpoint state |
94
- | `time_machine_diff` | `id?: string` | Returns unified file diff between current workspace and target checkpoint |
92
+ | `time_machine_checkpoint_create` | `label?: string, sessionId?: string` | Creates a shadow git workspace checkpoint before risky edits or operations |
93
+ | `time_machine_checkpoint_list` | `sessionId?: string` | Lists all recent workspace checkpoints newest first |
94
+ | `time_machine_checkpoint_rollback` | `id: string, confirm: boolean` | Safely reverts workspace files back to specified checkpoint without altering branch HEAD |
95
+ | `time_machine_diff` | `from: string, to?: string` | Returns unified file diff between current workspace and target checkpoint |
96
+ | `time_machine_checkpoint_delete` | `id: string, confirm: boolean` | Permanently deletes a single checkpoint and purges its git reference |
97
+ | `time_machine_checkpoint_prune` | `sessionId?: string, keep?: number` | Prunes session checkpoints, keeping only the newest N checkpoints |
95
98
 
96
99
  ---
97
100
 
@@ -114,6 +117,20 @@ dsh-time-machine:
114
117
 
115
118
  ---
116
119
 
120
+ ## 📋 Release Notes
121
+
122
+ ### v0.1.7 — Critical Safety, Staging Isolation & Native Event Bus
123
+ * **Changed in v0.1.7**: Safe workspace rollback via `read-tree` + `checkout-index` + `clean -fd`. Rolling back to a checkpoint never modifies branch `HEAD` or severs git commit history.
124
+ * **Changed in v0.1.7**: User staging area protection. Checkpoints now isolate git index creation through `GIT_INDEX_FILE`, preventing disruption of pre-staged files in `.git/index`.
125
+ * **Changed in v0.1.7**: Native DSH session event integration. Subscribed to `session/event` bus for automated checkpoints on `turn/start`, `approval/asked`, `turn/end`, and command errors.
126
+ * **Changed in v0.1.7**: Automatic Git ref cleanup on snapshot eviction to eliminate disk ref leaks.
127
+ * **Changed in v0.1.7**: Direct working directory diff computation when comparing checkpoints with uncommitted changes.
128
+ * **Changed in v0.1.7**: Strict compliance with DSH Plugin Authoring guidelines: form fields are enabled only when settings status is `ready`.
129
+ * **Added in v0.1.7**: WebServer request body size limit (1MB max payload) for DoS protection.
130
+ * **Added in v0.1.7**: Complete Chinese localization (`zh`) in frontend interface.
131
+
132
+ ---
133
+
117
134
  ## 📄 License
118
135
 
119
- MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
136
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
package/README.ru.md CHANGED
@@ -60,34 +60,41 @@ graph LR
60
60
 
61
61
  ---
62
62
 
63
- ## Ключевые возможности
63
+ ## 🌟 Ключевые возможности
64
64
 
65
- ### 1. 🛡️ Теневые снапшоты без засорения Git (`lib/snapshot.js`)
65
+ ### 1. 🛡️ Легковесные теневые Git-снапшоты
66
66
  * Фиксирует всё рабочее дерево, индексированные и новые файлы через изолированные shadow-ссылки Git;
67
- * Не создаёт лишних коммитов в пользовательской истории и не переключает активные ветки;
68
- * Хранит скользящую историю последних $N$ чекпоинтов с понятными метками и метками времени.
67
+ * Не создаёт лишних коммитов в пользовательской истории, не переключает активные ветки и не сдвигает указатель `HEAD`;
68
+ * Изолирует рабочий индекс `.git/index` от фонового сохранения чекпоинтов через переменную `GIT_INDEX_FILE`;
69
+ * Хранит скользящую историю последних $N$ чекпоинтов с понятными метками, сессионной привязкой и метками времени.
69
70
 
70
71
  ### 2. ⏪ Мгновенный безопасный откат (`time_machine_checkpoint_rollback`)
71
72
  * Возвращает всё рабочее пространство к любому предыдущему состоянию за миллисекунды;
73
+ * Не затирает историю веток Git: откат выполняется через безопасный `read-tree` + `checkout-index` + `clean -fd`;
72
74
  * Может вызываться как программно агентом, так и пользователем через кнопку в интерфейсе.
73
75
 
74
76
  ### 3. 🔍 Визуальный инспектор Diff (`time_machine_diff`)
75
- * Сравнивает текущие файлы со снапшотом и формирует наглядный пофайловый Diff.
77
+ * Сравнивает текущие файлы рабочего каталога со снапшотом и формирует наглядный пофайловый Diff.
76
78
 
77
- ### 4. 🕒 Интерактивная хроника в сайдбаре (`lib/client.js`)
78
- * Встраивается в боковую панель Web UI DSH;
79
- * Показывает список чекпоинтов со счётчиками изменённых файлов и кнопками «Откатить» и «Сравнить Diff».
79
+ ### 4. 🕒 Интерактивная хроника в сайдбаре и настройках (`lib/client.js`)
80
+ * Встраивается в боковую панель Web UI DSH и карточку настроек плагинов;
81
+ * Показывает список чекпоинтов с кнопками «Откатить», «Сравнить Diff» и «Удалить».
82
+
83
+ ### 5. 🩹 Авто-восстановление при сбоях команд
84
+ * Автоматически фиксирует контрольную точку при сбоях выполнения инструментов и команд.
80
85
 
81
86
  ---
82
87
 
83
- ## 🛠️ Инструменты агента (4 инструмента)
88
+ ## 🛠️ Инструменты агента (6 инструментов)
84
89
 
85
90
  | Имя инструмента | Параметры | Описание |
86
91
  |---|---|---|
87
- | `time_machine_checkpoint_create` | `label?: string` | Создаёт теневой чекпоинт перед рискованными правками |
88
- | `time_machine_checkpoint_list` | *(нет)* | Возвращает список недавних снапшотов от новых к старым |
89
- | `time_machine_checkpoint_rollback` | `id: string` | Откатывает файлы рабочего пространства к указанному снапшоту |
90
- | `time_machine_diff` | `id?: string` | Возвращает пофайловый Diff между текущим состоянием и чекпоинтом |
92
+ | `time_machine_checkpoint_create` | `label?: string, sessionId?: string` | Создаёт теневой чекпоинт перед рискованными правками |
93
+ | `time_machine_checkpoint_list` | `sessionId?: string` | Возвращает список недавних снапшотов от новых к старым |
94
+ | `time_machine_checkpoint_rollback` | `id: string, confirm: boolean` | Безопасно откатывает файлы рабочего пространства без изменения HEAD ветки |
95
+ | `time_machine_diff` | `from: string, to?: string` | Возвращает пофайловый Diff между текущим состоянием и чекпоинтом |
96
+ | `time_machine_checkpoint_delete` | `id: string, confirm: boolean` | Удаляет отдельный чекпоинт и очищает соответствующий Git ref |
97
+ | `time_machine_checkpoint_prune` | `sessionId?: string, keep?: number` | Прореживает чекпоинты сессии, сохраняя указанное количество самых свежих |
91
98
 
92
99
  ---
93
100
 
@@ -110,6 +117,20 @@ dsh-time-machine:
110
117
 
111
118
  ---
112
119
 
120
+ ## 📋 История версий (Release Notes)
121
+
122
+ ### v0.1.7 — Безопасность истории веток, изоляция индекса и события DSH
123
+ * **Changed in v0.1.7**: Безопасный откат рабочего каталога через `read-tree` + `checkout-index` + `clean -fd`. Устранена критическая проблема переноса `HEAD` ветки на сиротский коммит.
124
+ * **Changed in v0.1.7**: Защита пользовательского индекса Git. Чекпоинты создаются с изолированным `GIT_INDEX_FILE`, предотвращая перезапись подготовленных файлов в `.git/index`.
125
+ * **Changed in v0.1.7**: Поддержка нативной шины событий DSH `session/event` для автоматических чекпоинтов на `turn/start`, `approval/asked`, `turn/end` и аварийных снапшотов при ошибках.
126
+ * **Changed in v0.1.7**: Автоматическое удаление ссылок `refs/dsh-time-machine/...` из Git при вытеснении старых чекпоинтов по лимиту `maxSnapshots`.
127
+ * **Changed in v0.1.7**: Корректный расчет Diff напрямую относительно рабочей директории с незакоммиченными изменениями.
128
+ * **Changed in v0.1.7**: Соответствие стандартам DSH Plugin Authoring: поля формы настроек разблокированы строго в состоянии `ready`.
129
+ * **Added in v0.1.7**: Защита WebServer API от DoS-атак через лимит тела запроса (макс. 1 МБ).
130
+ * **Added in v0.1.7**: Полная китайская локализация интерфейса (`zh`).
131
+
132
+ ---
133
+
113
134
  ## 📄 Лицензия
114
135
 
115
- MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
136
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
package/README.zh.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  <div align="center">
4
4
 
5
- <h3>DeepSeek Harness 自动化影子 Git 快照、工作区时光机与一键即时回滚插件</h3>
5
+ <h3>面向 DeepSeek Harness 的影子 Git 自动快照、工作区时间旅行与即时回滚引擎</h3>
6
6
 
7
7
  <p align="center">
8
8
  <a href="https://www.npmjs.com/package/@goodandready/dsh-time-machine"><img src="https://img.shields.io/npm/v/@goodandready/dsh-time-machine.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
@@ -11,7 +11,7 @@
11
11
  <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
12
12
  </p>
13
13
 
14
- <!-- 官方展示中心跳转按钮 -->
14
+ <!-- 作者所有开源项目目录按钮 -->
15
15
  <p align="center">
16
16
  <a href="https://goodandready.app/"><img src="https://img.shields.io/badge/作者全部项目-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="作者全部项目"></a>
17
17
  </p>
@@ -60,6 +60,19 @@ graph LR
60
60
 
61
61
  ---
62
62
 
63
+ ## 🛠️ 智能体工具列表 (6 个工具)
64
+
65
+ | 工具名称 | 参数 | 说明 |
66
+ |---|---|---|
67
+ | `time_machine_checkpoint_create` | `label?: string, sessionId?: string` | 在高危修改前创建影子 Git 检查点快照 |
68
+ | `time_machine_checkpoint_list` | `sessionId?: string` | 按时间倒序列出最近的工作区检查点 |
69
+ | `time_machine_checkpoint_rollback` | `id: string, confirm: boolean` | 安全还原工作区文件至指定检查点(不移动分支 HEAD) |
70
+ | `time_machine_diff` | `from: string, to?: string` | 对比当前工作区文件与目标检查点的文件差异 |
71
+ | `time_machine_checkpoint_delete` | `id: string, confirm: boolean` | 删除指定检查点并同步清理 Git 引用 |
72
+ | `time_machine_checkpoint_prune` | `sessionId?: string, keep?: number` | 清理会话过期检查点,保留指定数量的最新快照 |
73
+
74
+ ---
75
+
63
76
  ## 📦 安装指南
64
77
 
65
78
  ```bash
@@ -68,6 +81,31 @@ dsh plugin --profile web add @goodandready/dsh-time-machine
68
81
 
69
82
  ---
70
83
 
84
+ ## ⚙️ 配置说明 (`settings.yaml`)
85
+
86
+ ```yaml
87
+ dsh-time-machine:
88
+ autoSnapshotEnabled: true # 文件修改前自动创建快照
89
+ maxSnapshots: 20 # 内存保留的最大滚动检查点数量
90
+ autoHealPrompt: true # 终端命令执行失败时提示回滚
91
+ ```
92
+
93
+ ---
94
+
95
+ ## 📋 版本更新记录 (Release Notes)
96
+
97
+ ### v0.1.7 — 分支历史安全、暂存区隔离与 DSH 原生事件总线
98
+ * **Changed in v0.1.7**: 安全工作区回滚。改用 `read-tree` + `checkout-index` + `clean -fd`,彻底杜绝回滚时误将分支 `HEAD` 覆盖为孤立提交的严重缺陷。
99
+ * **Changed in v0.1.7**: 保护用户 Git 暂存区。快照操作通过独立的 `GIT_INDEX_FILE` 执行,不会覆盖 `.git/index` 中已暂存的文件。
100
+ * **Changed in v0.1.7**: 原生对接 DSH `session/event` 事件总线,全面激活 `turn/start`、`approval/asked`、`turn/end` 和错误自愈事件监听。
101
+ * **Changed in v0.1.7**: 修复快照淘汰时的 Git 引用泄露问题,自动执行 `git update-ref -d`。
102
+ * **Changed in v0.1.7**: 修复 Diff 计算,直接对比当前工作区中的未提交修改。
103
+ * **Changed in v0.1.7**: 严格遵循 DSH 插件规范:仅在 `ready` 状态下允许编辑配置。
104
+ * **Added in v0.1.7**: WebServer API 增加 1MB 请求体大小上限,抵御 DoS 攻击。
105
+ * **Added in v0.1.7**: 前端界面完整支持中文本地化 (`zh`)。
106
+
107
+ ---
108
+
71
109
  ## 📄 开源协议
72
110
 
73
- MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
111
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
package/lib/client.js CHANGED
@@ -87,6 +87,28 @@ window.__ModuleLoader__.load({
87
87
  unavailable: 'Настройки недоступны',
88
88
  tabTitle: 'Машина времени',
89
89
  };
90
+ const zh = {
91
+ title: '时光机与检查点',
92
+ sub: '影子快照、差异对比与即时回滚,保障智能体安全。',
93
+ loading: '加载中…',
94
+ create: '创建检查点',
95
+ labelPh: '检查点标签',
96
+ list: '时间线',
97
+ empty: '暂无检查点。',
98
+ diff: '差异',
99
+ rollback: '回滚',
100
+ delete: '删除',
101
+ confirmDelete: '确定删除此检查点?',
102
+ confirmRollback: '确定回滚到此检查点?工作区将被重置。',
103
+ close: '关闭',
104
+ save: '保存',
105
+ saved: '已保存',
106
+ autoSnapshot: '文件变更前自动创建快照',
107
+ maxSnapshots: '最大快照数',
108
+ autoHeal: '命令失败时提示恢复',
109
+ unavailable: '设置不可用',
110
+ tabTitle: '时光机',
111
+ };
90
112
 
91
113
  let ChevronIcon = null;
92
114
  try {
@@ -102,8 +124,8 @@ window.__ModuleLoader__.load({
102
124
  function useLocale(ctx) {
103
125
  if (!ctx || !ctx.locale) return 'en';
104
126
  const lang = React.useSyncExternalStore(
105
- React.useMemo(() => (cb) => ctx.locale.subscribe(cb) ?? (() => {}), [ctx]),
106
- React.useCallback(() => ctx.locale.getSnapshot().active ?? 'en', [ctx])
127
+ React.useMemo(() => (cb) => ctx.locale.subscribe ? (ctx.locale.subscribe(cb) ?? (() => {})) : (() => {}), [ctx]),
128
+ React.useCallback(() => (ctx.locale.getSnapshot && ctx.locale.getSnapshot()?.active) || 'en', [ctx])
107
129
  );
108
130
  return lang;
109
131
  }
@@ -211,19 +233,13 @@ window.__ModuleLoader__.load({
211
233
 
212
234
  function PluginCard(props) {
213
235
  const ctx = props.ctx;
214
- const sid = sessionIdOf(ctx);
215
236
  const [expanded, setExpanded] = React.useState(false);
216
237
  const lang = useLocale(ctx);
217
- const t = lang === 'ru' ? ru : en;
218
- const [snapshots, setSnapshots] = React.useState([]);
219
- const [loading, setLoading] = React.useState(false);
220
- const [label, setLabel] = React.useState('');
221
- const [diffText, setDiffText] = React.useState(null);
222
- const [busy, setBusy] = React.useState(false);
223
- const [err, setErr] = React.useState('');
238
+ const t = lang.startsWith('zh') ? zh : (lang.startsWith('ru') ? ru : en);
224
239
  const [settingsStatus, setSettingsStatus] = React.useState('loading');
225
240
  const [draft, setDraft] = React.useState({ autoSnapshotEnabled: true, maxSnapshots: 20, autoHealPrompt: true });
226
241
  const [saving, setSaving] = React.useState(false);
242
+ const [saveErr, setSaveErr] = React.useState('');
227
243
 
228
244
  const scope = React.useMemo(() => {
229
245
  try { return ctx.settingsScope ? ctx.settingsScope.bind({ namespace: NS }) : null; } catch { return null; }
@@ -256,65 +272,22 @@ window.__ModuleLoader__.load({
256
272
  return () => { alive = false; try { unsub(); } catch {} };
257
273
  }, [scope]);
258
274
 
259
- const fetchList = React.useCallback(() => {
260
- setLoading(true); setErr('');
261
- const q = sid ? '?sessionId=' + encodeURIComponent(sid) : '';
262
- fetch('/dsh-time-machine/snapshots' + q).then(r => r.json()).then(j => {
263
- if (j.success) setSnapshots(j.snapshots || []);
264
- else setErr(j.error || 'load failed');
265
- }).catch(e => setErr(String(e.message || e))).finally(() => setLoading(false));
266
- }, [sid]);
267
-
268
- React.useEffect(() => { if (expanded) fetchList(); }, [expanded, fetchList]);
269
-
270
- const onCreate = () => {
271
- setBusy(true); setErr('');
272
- fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label, sessionId: sid }) })
273
- .then(r => r.json()).then(j => {
274
- if (!j.success) throw new Error(j.error || 'create failed');
275
- setLabel(''); fetchList();
276
- }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
277
- };
278
- const onDiff = (id) => {
279
- setBusy(true); setErr('');
280
- fetch('/dsh-time-machine/diff?from=' + encodeURIComponent(id)).then(r => r.json()).then(j => {
281
- if (!j.success) throw new Error(j.error || 'diff failed');
282
- setDiffText(j.diff || '');
283
- }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
284
- };
285
- const onRollback = (id) => {
286
- if (!window.confirm(t.confirmRollback)) return;
287
- setBusy(true); setErr('');
288
- fetch('/dsh-time-machine/rollback', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, confirm: true }) })
289
- .then(r => r.json()).then(j => {
290
- if (!j.success) throw new Error(j.error || 'rollback failed');
291
- fetchList();
292
- }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
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
- };
303
275
  const onSave = async () => {
304
276
  if (!scope) return;
305
- setSaving(true); setErr('');
277
+ setSaving(true); setSaveErr('');
306
278
  try {
307
279
  const keys = ['autoSnapshotEnabled', 'maxSnapshots', 'autoHealPrompt'];
308
280
  const failures = [];
309
281
  for (const k of keys) {
310
282
  try { await scope.set(k, draft[k]); } catch (e) { failures.push(k + ': ' + String(e.message || e)); }
311
283
  }
312
- if (failures.length) setErr(failures.join('; '));
313
- } catch (e) { setErr(String(e.message || e)); }
284
+ if (failures.length) setSaveErr(failures.join('; '));
285
+ } catch (e) { setSaveErr(String(e.message || e)); }
314
286
  finally { setSaving(false); }
315
287
  };
316
288
 
317
- const writable = settingsStatus === 'ready' || settingsStatus === 'unavailable';
289
+ // Strict adherence to DSH Plugin Authoring: writable ONLY when ready
290
+ const writable = settingsStatus === 'ready';
318
291
 
319
292
  return React.createElement('li', { className: 'tm-card' },
320
293
  React.createElement('button', { type: 'button', className: 'tm-head', 'aria-expanded': expanded, onClick: () => setExpanded(!expanded) },
@@ -339,42 +312,15 @@ window.__ModuleLoader__.load({
339
312
  React.createElement('input', { type: 'checkbox', checked: !!draft.autoHealPrompt, disabled: !writable, onChange: (e) => setDraft(d => ({ ...d, autoHealPrompt: e.target.checked })) }), ' ' + t.autoHeal
340
313
  )
341
314
  ),
315
+ saveErr ? React.createElement('div', { className: 'tm-label', style: { color: 'var(--dsw-alias-state-error-primary)' } }, saveErr) : null,
342
316
  React.createElement('div', { className: 'tm-foot' },
343
317
  React.createElement('button', { className: 'tm-save', disabled: !writable || saving, onClick: onSave }, saving ? t.loading : t.save)
344
318
  )
345
319
  ),
346
- React.createElement('div', { className: 'tm-field' },
347
- React.createElement('div', { style: { display: 'flex', gap: 8 } },
348
- React.createElement('input', { className: 'tm-input', style: { flex: 1 }, placeholder: t.labelPh, value: label, onChange: (e) => setLabel(e.target.value) }),
349
- React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: onCreate }, t.create)
350
- )
351
- ),
352
320
  React.createElement('div', { className: 'tm-field' },
353
321
  React.createElement('div', { className: 'tm-label' }, t.list),
354
- loading ? React.createElement('div', { className: 'tm-label' }, t.loading) : null,
355
- err ? React.createElement('div', { className: 'tm-label', style: { color: 'var(--dsw-alias-state-error-primary)' } }, err) : null,
356
- snapshots.length === 0 && !loading ? React.createElement('div', { className: 'tm-label' }, t.empty) : null,
357
- React.createElement('div', { className: 'tm-list' },
358
- snapshots.map(s => React.createElement('div', { key: s.id, className: 'tm-row' },
359
- React.createElement('span', { className: 'tm-row-main' },
360
- React.createElement('span', { className: 'tm-row-title' }, s.label + ' · ' + s.id),
361
- React.createElement('span', { className: 'tm-row-meta' }, new Date(s.createdAt).toLocaleString() + (s.commit ? ' · ' + s.commit.slice(0,7) : ''))
362
- ),
363
- React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onDiff(s.id) }, t.diff),
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)
366
- ))
367
- )
368
- ),
369
- diffText !== null ? React.createElement('div', { className: 'tm-modal', onClick: () => setDiffText(null) },
370
- React.createElement('div', { className: 'tm-modal-box', onClick: (e) => e.stopPropagation() },
371
- React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 } },
372
- React.createElement('strong', null, 'diff'),
373
- React.createElement('button', { className: 'tm-btn', onClick: () => setDiffText(null) }, t.close)
374
- ),
375
- React.createElement('pre', { style: { margin: 0, whiteSpace: 'pre-wrap' } }, diffText)
376
- )
377
- ) : null
322
+ React.createElement(Timeline, { t, ctx })
323
+ )
378
324
  ) : null
379
325
  );
380
326
  }
@@ -382,7 +328,7 @@ window.__ModuleLoader__.load({
382
328
  function TimeMachineTab(props) {
383
329
  const ctx = props.ctx;
384
330
  const lang = useLocale(ctx);
385
- const t = lang === 'ru' ? ru : en;
331
+ const t = lang.startsWith('zh') ? zh : (lang.startsWith('ru') ? ru : en);
386
332
  return React.createElement('div', { className: 'tm-tab' },
387
333
  React.createElement('div', { style: { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, t.tabTitle),
388
334
  React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: 4 } }, t.sub),
@@ -393,7 +339,7 @@ window.__ModuleLoader__.load({
393
339
  module.exports.inject = ['slots', 'locale'];
394
340
  module.exports.apply = function apply(ctx) {
395
341
  ensureStyles();
396
- try { ctx.locale && ctx.locale.register && ctx.locale.register(NS, { en, ru }); } catch {}
342
+ try { ctx.locale && ctx.locale.register && ctx.locale.register(NS, { en, ru, zh }); } catch {}
397
343
  // declaration-safe settings registration (alpha2 SlotCore requires inject)
398
344
  let settingsRegistered = false;
399
345
  const doRegisterSettings = () => {
@@ -423,7 +369,6 @@ window.__ModuleLoader__.load({
423
369
  ctx.slots.inject('settings.plugin.item', () => {
424
370
  doRegisterSettings();
425
371
  });
426
- // also handle delayed BetterSidebar via inject
427
372
  } catch (e) {
428
373
  console.error('[dsh-time-machine] slots.inject failed', e);
429
374
  doRegisterSettings();
@@ -441,8 +386,8 @@ window.__ModuleLoader__.load({
441
386
  svc.registerTab({
442
387
  id: 'time-machine',
443
388
  title: () => {
444
- const a = ctx.locale && ctx.locale.getSnapshot && ctx.locale.getSnapshot().active || 'en';
445
- return a.startsWith('ru') ? 'Машина времени' : 'Time Machine';
389
+ const a = (ctx.locale && ctx.locale.getSnapshot && ctx.locale.getSnapshot().active) || 'en';
390
+ return a.startsWith('zh') ? '时光机' : (a.startsWith('ru') ? 'Машина времени' : 'Time Machine');
446
391
  },
447
392
  order: 30,
448
393
  single: true,
@@ -464,7 +409,6 @@ window.__ModuleLoader__.load({
464
409
  return () => {};
465
410
  });
466
411
  } catch (e) {
467
- // betterSidebar is optional, do not hide loader failure
468
412
  if (String(e && e.message || '').includes('not declared')) {
469
413
  // service not declared in this host build - silently skip tab
470
414
  } else {
@@ -475,4 +419,4 @@ window.__ModuleLoader__.load({
475
419
  };
476
420
  return module.exports;
477
421
  },
478
- });
422
+ });
package/lib/index.js CHANGED
@@ -15,6 +15,12 @@ const NS = '@goodandready/dsh-time-machine';
15
15
  function sessionIdOf(execution, ctx) {
16
16
  try {
17
17
  if (execution && execution.sessionId) return String(execution.sessionId);
18
+ if (execution && execution.session) {
19
+ return String(execution.session.id || execution.session.header?.id || '');
20
+ }
21
+ if (execution && execution.data && execution.data.sessionId) {
22
+ return String(execution.data.sessionId);
23
+ }
18
24
  if (execution && execution.agent && execution.agent.session) {
19
25
  return String(execution.agent.session.id || execution.agent.session.header?.id || '');
20
26
  }
@@ -49,7 +55,35 @@ export function apply(ctx, config) {
49
55
  } catch {}
50
56
  };
51
57
 
52
- // listen to DSH events if available
58
+ // 1. Native DSH session/event bus (Cordis standard in DSH)
59
+ ctx.effect(() => {
60
+ const off = ctx.on && ctx.on('session/event', (session, event) => {
61
+ const sid = (session && session.id) || sessionIdOf(event, ctx);
62
+ const type = event && event.type;
63
+ if (type === 'turn/start') {
64
+ const turnId = event.turnId || event.data?.turnId || Date.now();
65
+ autoSnap(`auto:turn:${turnId}`, sid);
66
+ } else if (type === 'approval/asked') {
67
+ const tool = event.tool || event.data?.toolName || event.data?.tool || 'approval';
68
+ autoSnap(`auto:approval:${tool}`, sid);
69
+ } else if (type === 'turn/end') {
70
+ if (!getConfig().autoSnapshotEnabled) return;
71
+ const outcome = String((event && (event.outcome || event.data?.outcome || event.result?.outcome || event.result?.status)) || '');
72
+ if (!sid) return;
73
+ if (outcome === 'success' || outcome === 'ok' || outcome === 'done') {
74
+ engine.pruneSnapshots(sid, 3).catch(() => {});
75
+ }
76
+ } else if (type === 'tool/error' || type === 'command/error') {
77
+ if (getConfig().autoHealPrompt) {
78
+ const tool = event.tool || event.data?.toolName || 'command';
79
+ autoSnap(`auto:error:${tool}`, sid);
80
+ }
81
+ }
82
+ });
83
+ return () => { try { typeof off === 'function' && off(); } catch {} };
84
+ }, 'dsh-time-machine: native session events');
85
+
86
+ // 2. Legacy/mock events bus fallback (for unit tests and non-standard environments)
53
87
  try {
54
88
  if (ctx.events && typeof ctx.events.on === 'function') {
55
89
  ctx.effect(() => {
@@ -76,7 +110,6 @@ export function apply(ctx, config) {
76
110
  if (outcome === 'success' || outcome === 'ok' || outcome === 'done') {
77
111
  engine.pruneSnapshots(sid, 3).catch(() => {});
78
112
  }
79
- // failure/blocked: keep all checkpoints for review
80
113
  }));
81
114
  } catch {}
82
115
  return () => { for (const off of offs) try { typeof off === 'function' && off(); } catch {} };
@@ -114,10 +147,6 @@ export function apply(ctx, config) {
114
147
  },
115
148
  execute: async (args = {}, execution) => {
116
149
  const sid = args.sessionId != null ? String(args.sessionId).trim() : (sessionIdOf(execution, tctx) ? String(sessionIdOf(execution, tctx)).trim() : undefined);
117
- const list = sid !== undefined ? engine.listSnapshots(sid) : engine.listSnapshots();
118
- // if sid undefined and we want all, return all; but if sessionId was auto-detected, filter
119
- // For backward compat, if no sid provided and engine has sessionIds, return all
120
- // If sid was auto-detected (execution has session), use it
121
150
  const finalList = (args.sessionId == null && sid !== undefined && sid !== '') ? engine.listSnapshots(sid) : (args.sessionId != null ? engine.listSnapshots(String(args.sessionId)) : engine.listSnapshots());
122
151
  return { success: true, snapshots: finalList, sessionId: sid || '' };
123
152
  },
@@ -197,6 +226,33 @@ export function apply(ctx, config) {
197
226
  else ctx.inject(['tools'], (tctx) => registerTools(tctx));
198
227
  } catch {}
199
228
 
229
+ // Safe read body with max 1MB limit to protect against DoS
230
+ const readJsonBody = (req, res, cb) => {
231
+ let body = '';
232
+ let exceeded = false;
233
+ req.on('data', (c) => {
234
+ body += c;
235
+ if (body.length > 1024 * 1024) {
236
+ exceeded = true;
237
+ res.statusCode = 413;
238
+ res.setHeader('content-type', 'application/json');
239
+ res.end(JSON.stringify({ success: false, error: 'payload too large' }));
240
+ req.destroy();
241
+ }
242
+ });
243
+ req.on('end', () => {
244
+ if (exceeded) return;
245
+ try {
246
+ const parsed = body ? JSON.parse(body) : {};
247
+ cb(parsed);
248
+ } catch (e) {
249
+ res.statusCode = 400;
250
+ res.setHeader('content-type', 'application/json');
251
+ res.end(JSON.stringify({ success: false, error: 'invalid json' }));
252
+ }
253
+ });
254
+ };
255
+
200
256
  // web routes for UI (session-aware via ?sessionId=)
201
257
  ctx.effect(() => {
202
258
  const disposals = [];
@@ -233,12 +289,9 @@ export function apply(ctx, config) {
233
289
  disposals.push(ctx.webServer.register({
234
290
  kind: 'exact',
235
291
  path: '/dsh-time-machine/create',
236
- handler: async (req, res) => {
237
- let body = '';
238
- req.on('data', (c) => body += c);
239
- req.on('end', async () => {
292
+ handler: (req, res) => {
293
+ readJsonBody(req, res, async (parsed) => {
240
294
  try {
241
- const parsed = body ? JSON.parse(body) : {};
242
295
  const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '').trim();
243
296
  const snap = await engine.createSnapshot(parsed.label, { sessionId: sid });
244
297
  res.setHeader('content-type', 'application/json');
@@ -254,12 +307,9 @@ export function apply(ctx, config) {
254
307
  disposals.push(ctx.webServer.register({
255
308
  kind: 'exact',
256
309
  path: '/dsh-time-machine/delete',
257
- handler: async (req, res) => {
258
- let body = '';
259
- req.on('data', (c) => body += c);
260
- req.on('end', async () => {
310
+ handler: (req, res) => {
311
+ readJsonBody(req, res, async (parsed) => {
261
312
  try {
262
- const parsed = body ? JSON.parse(body) : {};
263
313
  const out = await engine.deleteSnapshot(String(parsed.id || ''), { confirm: parsed.confirm });
264
314
  res.setHeader('content-type', 'application/json');
265
315
  res.end(JSON.stringify({ success: true, ...out }));
@@ -274,12 +324,9 @@ export function apply(ctx, config) {
274
324
  disposals.push(ctx.webServer.register({
275
325
  kind: 'exact',
276
326
  path: '/dsh-time-machine/prune',
277
- handler: async (req, res) => {
278
- let body = '';
279
- req.on('data', (c) => body += c);
280
- req.on('end', async () => {
327
+ handler: (req, res) => {
328
+ readJsonBody(req, res, async (parsed) => {
281
329
  try {
282
- const parsed = body ? JSON.parse(body) : {};
283
330
  const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '');
284
331
  const out = await engine.pruneSnapshots(sid, parsed.keep);
285
332
  res.setHeader('content-type', 'application/json');
@@ -295,12 +342,9 @@ export function apply(ctx, config) {
295
342
  disposals.push(ctx.webServer.register({
296
343
  kind: 'exact',
297
344
  path: '/dsh-time-machine/rollback',
298
- handler: async (req, res) => {
299
- let body = '';
300
- req.on('data', (c) => body += c);
301
- req.on('end', async () => {
345
+ handler: (req, res) => {
346
+ readJsonBody(req, res, async (parsed) => {
302
347
  try {
303
- const parsed = body ? JSON.parse(body) : {};
304
348
  const out = await engine.rollbackSnapshot(String(parsed.id || ''), { confirm: parsed.confirm });
305
349
  res.setHeader('content-type', 'application/json');
306
350
  res.end(JSON.stringify({ success: true, ...out }));
@@ -317,4 +361,4 @@ export function apply(ctx, config) {
317
361
  }, 'dsh-time-machine: web routes');
318
362
 
319
363
  ctx.provide?.('timeMachineEngine', engine);
320
- }
364
+ }
package/lib/snapshot.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import crypto from 'node:crypto';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs/promises';
2
4
  import { execFile as execFileCb } from 'node:child_process';
3
5
  import { promisify } from 'node:util';
4
6
 
@@ -6,7 +8,7 @@ const execFile = promisify(execFileCb);
6
8
 
7
9
  async function defaultExec(cmd, args, opts = {}) {
8
10
  try {
9
- const { stdout } = await execFile(cmd, args, { encoding: 'utf8', ...opts });
11
+ const { stdout } = await execFile(cmd, args, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, ...opts });
10
12
  return { stdout: String(stdout ?? ''), stderr: '' };
11
13
  } catch (e) {
12
14
  const err = e;
@@ -28,16 +30,26 @@ export class ShadowSnapshotEngine {
28
30
 
29
31
  _now() { return Date.now(); }
30
32
 
31
- _trimFor(sessionId) {
33
+ async _trimFor(sessionId) {
32
34
  if (!sessionId) {
33
- while (this.snapshots.length > this.maxSnapshots) this.snapshots.shift();
35
+ while (this.snapshots.length > this.maxSnapshots) {
36
+ const oldest = this.snapshots.shift();
37
+ if (oldest?.ref) {
38
+ try { await this.exec('git', ['update-ref', '-d', oldest.ref], this.cwd ? { cwd: this.cwd } : {}); } catch {}
39
+ }
40
+ }
34
41
  return;
35
42
  }
36
43
  const perSession = this.snapshots.filter(s => s.sessionId === sessionId);
37
44
  while (perSession.length > this.maxSnapshots) {
38
45
  const oldest = perSession.shift();
39
46
  const idx = this.snapshots.indexOf(oldest);
40
- if (idx !== -1) this.snapshots.splice(idx, 1);
47
+ if (idx !== -1) {
48
+ this.snapshots.splice(idx, 1);
49
+ if (oldest?.ref) {
50
+ try { await this.exec('git', ['update-ref', '-d', oldest.ref], this.cwd ? { cwd: this.cwd } : {}); } catch {}
51
+ }
52
+ }
41
53
  }
42
54
  }
43
55
 
@@ -48,6 +60,17 @@ export class ShadowSnapshotEngine {
48
60
  } catch { return false; }
49
61
  }
50
62
 
63
+ async _gitDir() {
64
+ try {
65
+ const { stdout } = await this.exec('git', ['rev-parse', '--git-dir'], this.cwd ? { cwd: this.cwd } : {});
66
+ const gdir = String(stdout ?? '').trim();
67
+ if (!gdir) return null;
68
+ return path.isAbsolute(gdir) ? gdir : path.resolve(this.cwd || process.cwd(), gdir);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
51
74
  // Restore checkpoint metadata from git shadow refs after a restart.
52
75
  // git is the durable store; memory is rebuilt from refs/dsh-time-machine/*.
53
76
  async loadFromRefs() {
@@ -89,8 +112,15 @@ export class ShadowSnapshotEngine {
89
112
  for (const snap of restored) snap.seq = ++this.seq;
90
113
  this.snapshots.push(...restored);
91
114
  const sessions = [...new Set(this.snapshots.map(s => s.sessionId))];
92
- for (const sid of sessions) this._trimFor(sid);
93
- if (sessions.length === 0) while (this.snapshots.length > this.maxSnapshots) this.snapshots.shift();
115
+ for (const sid of sessions) await this._trimFor(sid);
116
+ if (sessions.length === 0) {
117
+ while (this.snapshots.length > this.maxSnapshots) {
118
+ const oldest = this.snapshots.shift();
119
+ if (oldest?.ref) {
120
+ try { await this.exec('git', ['update-ref', '-d', oldest.ref], this.cwd ? { cwd: this.cwd } : {}); } catch {}
121
+ }
122
+ }
123
+ }
94
124
  return restored.length;
95
125
  }
96
126
 
@@ -98,27 +128,49 @@ export class ShadowSnapshotEngine {
98
128
  const id = crypto.randomUUID().slice(0, 8);
99
129
  const createdAt = this._now();
100
130
  const safeLabel = String(label ?? '').trim() || `checkpoint-${id}`;
101
- const sid = String(sessionId || '').trim();
131
+ const rawSid = String(sessionId || '').trim();
132
+ const safeSid = rawSid ? rawSid.replace(/[^a-zA-Z0-9_-]/g, '_') : '';
102
133
  let commit = null;
103
134
  let ref = null;
104
135
  const inGit = await this._isGitRepo();
105
136
  if (inGit) {
137
+ let shadowIndex = null;
106
138
  try {
107
- await this.exec('git', ['add', '-A'], this.cwd ? { cwd: this.cwd } : {});
108
- const { stdout: tree } = await this.exec('git', ['write-tree'], this.cwd ? { cwd: this.cwd } : {});
139
+ const gitDir = await this._gitDir();
140
+ const baseOpts = this.cwd ? { cwd: this.cwd } : {};
141
+ const execEnv = {
142
+ ...process.env,
143
+ GIT_AUTHOR_NAME: 'DSH Time Machine',
144
+ GIT_AUTHOR_EMAIL: 'time-machine@dsh.local',
145
+ GIT_COMMITTER_NAME: 'DSH Time Machine',
146
+ GIT_COMMITTER_EMAIL: 'time-machine@dsh.local',
147
+ };
148
+
149
+ if (gitDir) {
150
+ shadowIndex = path.join(gitDir, `tm_index_${id}`);
151
+ execEnv.GIT_INDEX_FILE = shadowIndex;
152
+ }
153
+
154
+ const shadowOpts = { ...baseOpts, env: execEnv };
155
+ await this.exec('git', ['add', '-A'], shadowOpts);
156
+ const { stdout: tree } = await this.exec('git', ['write-tree'], shadowOpts);
109
157
  const treeHash = String(tree).trim();
110
- const { stdout: commitHash } = await this.exec('git', ['commit-tree', treeHash, '-m', safeLabel], this.cwd ? { cwd: this.cwd } : {});
158
+ const { stdout: commitHash } = await this.exec('git', ['commit-tree', treeHash, '-m', safeLabel], shadowOpts);
111
159
  commit = String(commitHash).trim();
112
- ref = sid ? `refs/dsh-time-machine/${sid}/${id}` : `refs/dsh-time-machine/${id}`;
113
- await this.exec('git', ['update-ref', ref, commit], this.cwd ? { cwd: this.cwd } : {});
160
+ ref = safeSid ? `refs/dsh-time-machine/${safeSid}/${id}` : `refs/dsh-time-machine/${id}`;
161
+ await this.exec('git', ['update-ref', ref, commit], baseOpts);
114
162
  } catch {
115
163
  commit = null;
116
164
  ref = null;
165
+ } finally {
166
+ if (shadowIndex) {
167
+ try { await fs.unlink(shadowIndex); } catch {}
168
+ }
117
169
  }
118
170
  }
119
- const snap = { id, label: safeLabel, sessionId: sid, createdAt, commit, ref, seq: ++this.seq };
171
+ const snap = { id, label: safeLabel, sessionId: rawSid, createdAt, commit, ref, seq: ++this.seq };
120
172
  this.snapshots.push(snap);
121
- this._trimFor(sid);
173
+ await this._trimFor(rawSid);
122
174
  return snap;
123
175
  }
124
176
 
@@ -146,11 +198,16 @@ export class ShadowSnapshotEngine {
146
198
  }
147
199
  const inGit = await this._isGitRepo();
148
200
  if (inGit && snap.commit) {
201
+ const opts = this.cwd ? { cwd: this.cwd } : {};
149
202
  try {
150
- await this.exec('git', ['reset', '--hard', snap.commit], this.cwd ? { cwd: this.cwd } : {});
203
+ // Safe rollback: restore index and working tree to snapshot without moving branch HEAD
204
+ await this.exec('git', ['read-tree', snap.commit], opts);
205
+ await this.exec('git', ['checkout-index', '-a', '-f'], opts);
206
+ await this.exec('git', ['clean', '-fd'], opts);
151
207
  } catch (e) {
152
- await this.exec('git', ['read-tree', snap.commit], this.cwd ? { cwd: this.cwd } : {}).catch(()=>{});
153
- await this.exec('git', ['checkout-index', '-a', '-f'], this.cwd ? { cwd: this.cwd } : {}).catch(()=>{});
208
+ // Fallback: checkout files directly from commit tree
209
+ await this.exec('git', ['checkout', snap.commit, '--', '.'], opts).catch(() => {});
210
+ await this.exec('git', ['clean', '-fd'], opts).catch(() => {});
154
211
  }
155
212
  }
156
213
  return { rolledBack: true, snapshot: snap };
@@ -181,7 +238,7 @@ export class ShadowSnapshotEngine {
181
238
  async pruneSnapshots(sessionId, keep = 3) {
182
239
  const sid = sessionId != null ? String(sessionId).trim() : undefined;
183
240
  const list = this.listSnapshots(sid); // newest first
184
- const minKeep = Math.max(0, Number(keep) || 3);
241
+ const minKeep = Math.max(0, Number.isFinite(Number(keep)) ? Number(keep) : 3);
185
242
  const toRemove = list.slice(minKeep);
186
243
  const removed = [];
187
244
  for (const snap of toRemove) {
@@ -200,15 +257,20 @@ export class ShadowSnapshotEngine {
200
257
  let to = toId ? this.getSnapshot(toId) : null;
201
258
  const inGit = await this._isGitRepo();
202
259
  if (inGit && from.commit) {
260
+ const opts = this.cwd ? { cwd: this.cwd } : {};
203
261
  try {
204
- const range = to?.commit ? `${from.commit}..${to.commit}` : `${from.commit}..HEAD`;
205
- const { stdout } = await this.exec('git', ['diff', '--stat', range], this.cwd ? { cwd: this.cwd } : {});
262
+ const args = to?.commit
263
+ ? ['diff', '--stat', `${from.commit}..${to.commit}`]
264
+ : ['diff', '--stat', from.commit];
265
+ const { stdout } = await this.exec('git', args, opts);
206
266
  const diffStat = String(stdout ?? '').trim();
207
267
  if (diffStat) return { from: fromId, to: toId || 'HEAD', diff: diffStat };
208
268
  } catch {}
209
269
  try {
210
- const range = to?.commit ? `${from.commit}..${to.commit}` : from.commit;
211
- const { stdout } = await this.exec('git', ['diff', '--name-only', range], this.cwd ? { cwd: this.cwd } : {});
270
+ const args = to?.commit
271
+ ? ['diff', '--name-only', `${from.commit}..${to.commit}`]
272
+ : ['diff', '--name-only', from.commit];
273
+ const { stdout } = await this.exec('git', args, opts);
212
274
  return { from: fromId, to: toId || 'HEAD', diff: String(stdout ?? '').trim() || '(no changes)' };
213
275
  } catch {}
214
276
  }
@@ -218,11 +280,15 @@ export class ShadowSnapshotEngine {
218
280
 
219
281
  setMax(n) {
220
282
  this.maxSnapshots = Math.max(1, Number(n) || 20);
221
- // trim all sessions
222
283
  const sessions = [...new Set(this.snapshots.map(s => s.sessionId))];
223
- for (const sid of sessions) this._trimFor(sid);
284
+ for (const sid of sessions) this._trimFor(sid).catch(() => {});
224
285
  if (sessions.length === 0) {
225
- while (this.snapshots.length > this.maxSnapshots) this.snapshots.shift();
286
+ while (this.snapshots.length > this.maxSnapshots) {
287
+ const oldest = this.snapshots.shift();
288
+ if (oldest?.ref) {
289
+ this.exec('git', ['update-ref', '-d', oldest.ref], this.cwd ? { cwd: this.cwd } : {}).catch(() => {});
290
+ }
291
+ }
226
292
  }
227
293
  }
228
- }
294
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-time-machine",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "DSH plugin for smart checkpoints, workspace safety guards, and instant rollback",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -42,9 +42,7 @@
42
42
  },
43
43
  "client": {
44
44
  "platform": "web",
45
- "inject": [
46
- "@deepseek-ai/dsh-client-ui-slots"
47
- ]
45
+ "inject": []
48
46
  }
49
47
  },
50
48
  "peerDependencies": {