@goodandready/dsh-time-machine 0.1.8 → 0.1.10

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
@@ -119,6 +119,21 @@ dsh-time-machine:
119
119
 
120
120
  ## 📋 Release Notes
121
121
 
122
+ ### v0.1.10 — Stability, Dynamic CWD, Unified Diff & Architecture Polish
123
+ * **Added in v0.1.10**: Dynamic / session-aware `cwd` resolution: TimeMachine tools and REST endpoints automatically locate the active session workspace or accept explicit target directory.
124
+ * **Added in v0.1.10**: Unified diff support (`format: "patch" | "stat"`) with size limits (up to 256KB) and clean truncation warnings.
125
+ * **Added in v0.1.10**: Async execution queue on Git mutation engine to completely eliminate lockfile collisions under concurrent agent operations.
126
+ * **Added in v0.1.10**: Single-batch Git ref querying using `git for-each-ref` replacing $O(N)$ sequential `git log` processes for instant snapshot listings.
127
+ * **Added in v0.1.10**: Redundant commit deduplication: auto-snapshots are safely skipped if the working tree has not changed.
128
+ * **Added in v0.1.10**: Startup cleanup of temporary `tm_index_*` staging files.
129
+ * **Changed in v0.1.10**: Settings UI card streamlined, directing snapshot inspection to the dedicated Time Machine sidebar tab.
130
+ * **Changed in v0.1.10**: Comprehensive error logging replacing silent exceptions.
131
+
132
+ ### v0.1.9 — Event Bus Harmonization & Dependency Cleanup
133
+ * **Changed in v0.1.9**: Harmonized event bus subscriptions to prevent duplicate checkpoints. Native DSH `session/event` bus takes precedence, with legacy `ctx.events` used strictly as a fallback when `ctx.on` is unavailable.
134
+ * **Changed in v0.1.9**: Removed unused `@deepseek-ai/dsh-credentials` from `peerDependencies`.
135
+ * **Added in v0.1.9**: Standardized project design contract in `docs/design/DESIGN.md`.
136
+
122
137
  ### v0.1.7 — Critical Safety, Staging Isolation & Native Event Bus
123
138
  * **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
139
  * **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`.
package/README.ru.md CHANGED
@@ -119,6 +119,21 @@ dsh-time-machine:
119
119
 
120
120
  ## 📋 История версий (Release Notes)
121
121
 
122
+ ### v0.1.10 — Стабильность, динамический CWD, Unified Diff и оптимизация архитектуры
123
+ * **Added in v0.1.10**: Динамическое определение рабочей директории (`cwd`): инструменты и REST API автоматически определяют каталог активной сессии либо принимают явный путь.
124
+ * **Added in v0.1.10**: Поддержка Unified Diff (`format: "patch" | "stat"`) с лимитом размера (до 256 КБ) и защитой от переполнения контекста.
125
+ * **Added in v0.1.10**: Асинхронная очередь мутаций Git, исключающая конфликты блокировок `index.lock` при одновременных запросах.
126
+ * **Added in v0.1.10**: Пакетная вычитка ссылок через `git for-each-ref` вместо $O(N)$ отдельных вызовов `git log`, ускоряющая загрузку списка чекпоинтов.
127
+ * **Added in v0.1.10**: Дедупликация снапшотов: авто-сохранение пропускается, если дерево файлов не изменилось с момента прошлого чекпоинта.
128
+ * **Added in v0.1.10**: Автоматическая очистка сиротских временных индексов `tm_index_*` при запуске плагина.
129
+ * **Changed in v0.1.10**: Оптимизирована карточка настроек: удалено дублирование ленты, добавлено указание на вкладку в боковой панели.
130
+ * **Changed in v0.1.10**: Полное логирование ошибок через `console.warn` вместо тихого подавления исключений.
131
+
132
+ ### v0.1.9 — Гармонизация шины событий и очистка зависимостей
133
+ * **Changed in v0.1.9**: Устранено потенциальное дублирование авто-снапшотов. Нативная шина событий DSH `session/event` теперь имеет приоритет, а legacy `ctx.events` подключается строго как fallback при отсутствии `ctx.on`.
134
+ * **Changed in v0.1.9**: Удалена неиспользуемая зависимость `@deepseek-ai/dsh-credentials` из `peerDependencies`.
135
+ * **Added in v0.1.9**: Добавлен дизайн-контракт проекта `docs/design/DESIGN.md`.
136
+
122
137
  ### v0.1.7 — Безопасность истории веток, изоляция индекса и события DSH
123
138
  * **Changed in v0.1.7**: Безопасный откат рабочего каталога через `read-tree` + `checkout-index` + `clean -fd`. Устранена критическая проблема переноса `HEAD` ветки на сиротский коммит.
124
139
  * **Changed in v0.1.7**: Защита пользовательского индекса Git. Чекпоинты создаются с изолированным `GIT_INDEX_FILE`, предотвращая перезапись подготовленных файлов в `.git/index`.
package/README.zh.md CHANGED
@@ -94,6 +94,21 @@ dsh-time-machine:
94
94
 
95
95
  ## 📋 版本更新记录 (Release Notes)
96
96
 
97
+ ### v0.1.10 — 稳定性、动态工作目录、统一 Diff 与架构优化
98
+ * **Added in v0.1.10**: 动态工作区感知(`cwd`):时光机工具与 REST 接口自动关联当前会话目录或支持显式工作区路径。
99
+ * **Added in v0.1.10**: 统一补丁支持(`format: "patch" | "stat"`),带输出截断保护(最大 256KB)。
100
+ * **Added in v0.1.10**: Git 变动操作异步排队队列,彻底解决高并发下的 `index.lock` 竞争冲突。
101
+ * **Added in v0.1.10**: 采用 `git for-each-ref` 批量查询引用,消除 $O(N)$ 次 `git log` 进程生成,极大加速快照加载。
102
+ * **Added in v0.1.10**: 空提交去重:若工作树无变化,自动跳过重复快照创建。
103
+ * **Added in v0.1.10**: 启动时自动清理遗留的 `tm_index_*` 临时暂存索引文件。
104
+ * **Changed in v0.1.10**: 精简设置卡片界面,引导在侧边栏时光机标签页查看时间线。
105
+ * **Changed in v0.1.10**: 完善错误告警日志(`console.warn`),替换静默吞并异常。
106
+
107
+ ### v0.1.9 — 事件总线统一与依赖清理
108
+ * **Changed in v0.1.9**: 统一事件总线监听逻辑,杜绝自动快照重复触发。优先使用原生 DSH `session/event` 总线,仅在缺失 `ctx.on` 时启用 legacy `ctx.events` 回退。
109
+ * **Changed in v0.1.9**: 从 `peerDependencies` 中移除未使用的 `@deepseek-ai/dsh-credentials`。
110
+ * **Added in v0.1.9**: 新增项目设计规范文档 `docs/design/DESIGN.md`。
111
+
97
112
  ### v0.1.7 — 分支历史安全、暂存区隔离与 DSH 原生事件总线
98
113
  * **Changed in v0.1.7**: 安全工作区回滚。改用 `read-tree` + `checkout-index` + `clean -fd`,彻底杜绝回滚时误将分支 `HEAD` 覆盖为孤立提交的严重缺陷。
99
114
  * **Changed in v0.1.7**: 保护用户 Git 暂存区。快照操作通过独立的 `GIT_INDEX_FILE` 执行,不会覆盖 `.git/index` 中已暂存的文件。
package/lib/client.js CHANGED
@@ -64,6 +64,7 @@ window.__ModuleLoader__.load({
64
64
  autoHeal: 'Prompt recovery on failure',
65
65
  unavailable: 'Settings unavailable',
66
66
  tabTitle: 'Time Machine',
67
+ sidebarNotice: 'Checkpoints and timeline are available in the Time Machine sidebar tab.',
67
68
  };
68
69
  const ru = {
69
70
  title: 'Машина времени и чекпоинты',
@@ -86,6 +87,7 @@ window.__ModuleLoader__.load({
86
87
  autoHeal: 'Предлагать восстановление при ошибке',
87
88
  unavailable: 'Настройки недоступны',
88
89
  tabTitle: 'Машина времени',
90
+ sidebarNotice: 'Лента чекпоинтов и управление снимками доступны во вкладке «Машина времени» боковой панели.',
89
91
  };
90
92
  const zh = {
91
93
  title: '时光机与检查点',
@@ -108,6 +110,7 @@ window.__ModuleLoader__.load({
108
110
  autoHeal: '命令失败时提示恢复',
109
111
  unavailable: '设置不可用',
110
112
  tabTitle: '时光机',
113
+ sidebarNotice: '检查点时间线与管理可在侧边栏“时光机”标签页中查看。',
111
114
  };
112
115
 
113
116
  let ChevronIcon = null;
@@ -176,7 +179,7 @@ window.__ModuleLoader__.load({
176
179
  };
177
180
  const onDiff = (id) => {
178
181
  setBusy(true); setErr('');
179
- fetch('/dsh-time-machine/diff?from=' + encodeURIComponent(id)).then(r => r.json()).then(j => {
182
+ fetch('/dsh-time-machine/diff?from=' + encodeURIComponent(id) + '&format=patch').then(r => r.json()).then(j => {
180
183
  if (!j.success) throw new Error(j.error || 'diff failed');
181
184
  setDiffText(j.diff || '');
182
185
  }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
@@ -317,9 +320,8 @@ window.__ModuleLoader__.load({
317
320
  React.createElement('button', { className: 'tm-save', disabled: !writable || saving, onClick: onSave }, saving ? t.loading : t.save)
318
321
  )
319
322
  ),
320
- React.createElement('div', { className: 'tm-field' },
321
- React.createElement('div', { className: 'tm-label' }, t.list),
322
- React.createElement(Timeline, { t, ctx })
323
+ React.createElement('div', { className: 'tm-field', style: { borderTop: '1px solid var(--dsw-alias-border-subtle, rgba(255,255,255,0.08))', paddingTop: 10, marginTop: 6 } },
324
+ React.createElement('div', { className: 'tm-label', style: { color: 'var(--dsw-alias-label-secondary)', fontStyle: 'italic' } }, t.sidebarNotice)
323
325
  )
324
326
  ) : null
325
327
  );
package/lib/index.js CHANGED
@@ -12,112 +12,124 @@ export const Config = Schema.object({
12
12
 
13
13
  const NS = '@goodandready/dsh-time-machine';
14
14
 
15
- function sessionIdOf(execution, ctx) {
15
+ function cwdOf(execution, ctx) {
16
16
  try {
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
- }
24
- if (execution && execution.agent && execution.agent.session) {
25
- return String(execution.agent.session.id || execution.agent.session.header?.id || '');
26
- }
27
- if (ctx && ctx.session) return String(ctx.session.id || ctx.session.header?.id || '');
28
- if (ctx && ctx.get && typeof ctx.get === 'function') {
29
- try { const s = ctx.get('session'); if (s) return String(s.id || s.header?.id || ''); } catch {}
17
+ if (execution?.cwd) return String(execution.cwd);
18
+ if (execution?.session?.workspace?.cwd) return String(execution.session.workspace.cwd);
19
+ if (execution?.session?.cwd) return String(execution.session.cwd);
20
+ if (ctx?.workspace?.cwd) return String(ctx.workspace.cwd);
21
+ if (typeof ctx?.get === 'function') {
22
+ const ws = ctx.get('workspace');
23
+ if (ws?.cwd) return String(ws.cwd);
30
24
  }
31
25
  } catch {}
26
+ return undefined;
27
+ }
28
+
29
+ function sessionIdOf(execution, ctx) {
30
+ try {
31
+ if (execution?.sessionId) return String(execution.sessionId);
32
+ if (execution?.session?.id) return String(execution.session.id);
33
+ if (execution?.data?.sessionId) return String(execution.data.sessionId);
34
+ if (ctx?.session?.id) return String(ctx.session.id);
35
+ } catch {}
32
36
  return '';
33
37
  }
34
38
 
35
39
  export function apply(ctx, config) {
36
40
  let getConfig = () => config;
37
- let engine = new ShadowSnapshotEngine({ maxSnapshots: config?.maxSnapshots ?? 20 });
38
- engine.loadFromRefs().catch(() => {});
41
+ const initialCwd = cwdOf(null, ctx);
42
+ let engine = new ShadowSnapshotEngine({ maxSnapshots: config?.maxSnapshots ?? 20, cwd: initialCwd });
43
+ engine.loadFromRefs({ cwd: initialCwd }).catch(() => {});
39
44
 
40
45
  ctx.inject(['settings'], (sctx) => {
41
46
  const scope = sctx.settings.register(NS, Config, { base: config });
42
47
  getConfig = () => scope.get() ?? config;
43
- const syncMax = () => engine.setMax(getConfig().maxSnapshots ?? 20);
48
+ const syncMax = () => engine.setMax(getConfig().maxSnapshots ?? 20, cwdOf(null, ctx));
44
49
  syncMax();
45
50
  const stop = scope.watch ? scope.watch(syncMax) : () => {};
46
51
  sctx.effect(() => () => { try { stop(); } catch {} }, 'dsh-time-machine: settings watch');
47
52
  });
48
53
 
49
54
  // auto-snapshot on turn/start and approval/asked (session-scoped)
50
- const autoSnap = async (label, sessId) => {
55
+ const autoSnap = async (label, sessId, customCwd) => {
51
56
  if (!getConfig().autoSnapshotEnabled) return;
52
57
  try {
53
58
  const sid = String(sessId || '').trim();
54
- await engine.createSnapshot(label, { sessionId: sid });
59
+ const targetCwd = customCwd || cwdOf(null, ctx);
60
+ await engine.createSnapshot(label, { sessionId: sid, cwd: targetCwd, skipIfNoChanges: true });
55
61
  } catch {}
56
62
  };
57
63
 
58
64
  // 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);
65
+ // When native session/event bus is present, use it exclusively to prevent double-fire.
66
+ const hasNativeEvents = typeof ctx.on === 'function';
67
+
68
+ if (hasNativeEvents) {
69
+ ctx.effect(() => {
70
+ const off = ctx.on('session/event', (session, event) => {
71
+ const sid = (session && session.id) || sessionIdOf(event, ctx);
72
+ const sessionCwd = session?.workspace?.cwd || session?.cwd || cwdOf(event, ctx);
73
+ const type = event && event.type;
74
+ if (type === 'turn/start') {
75
+ const turnId = event.turnId || event.data?.turnId || Date.now();
76
+ autoSnap(`auto:turn:${turnId}`, sid, sessionCwd);
77
+ } else if (type === 'approval/asked') {
78
+ const tool = event.tool || event.data?.toolName || event.data?.tool || 'approval';
79
+ autoSnap(`auto:approval:${tool}`, sid, sessionCwd);
80
+ } else if (type === 'turn/end') {
81
+ if (!getConfig().autoSnapshotEnabled) return;
82
+ const outcome = String((event && (event.outcome || event.data?.outcome || event.result?.outcome || event.result?.status)) || '');
83
+ if (!sid) return;
84
+ if (outcome === 'success' || outcome === 'ok' || outcome === 'done') {
85
+ engine.pruneSnapshots(sid, 3, { cwd: sessionCwd }).catch(() => {});
86
+ }
87
+ } else if (type === 'tool/error' || type === 'command/error') {
88
+ if (getConfig().autoHealPrompt) {
89
+ const tool = event.tool || event.data?.toolName || 'command';
90
+ autoSnap(`auto:error:${tool}`, sid, sessionCwd);
91
+ }
80
92
  }
93
+ });
94
+ return () => { try { typeof off === 'function' && off(); } catch {} };
95
+ }, 'dsh-time-machine: native session events');
96
+ } else {
97
+ // 2. Legacy/mock events bus fallback (for unit tests and non-standard environments without ctx.on)
98
+ try {
99
+ if (ctx.events && typeof ctx.events.on === 'function') {
100
+ ctx.effect(() => {
101
+ const offs = [];
102
+ try {
103
+ offs.push(ctx.events.on('turn/start', (ev) => {
104
+ const sid = sessionIdOf(ev, ctx);
105
+ autoSnap(`auto:turn:${ev?.turnId || Date.now()}`, sid);
106
+ }));
107
+ } catch {}
108
+ try {
109
+ offs.push(ctx.events.on('approval/asked', (ev) => {
110
+ const sid = sessionIdOf(ev, ctx);
111
+ const tool = ev?.tool || ev?.name || 'approval';
112
+ autoSnap(`auto:approval:${tool}`, sid);
113
+ }));
114
+ } catch {}
115
+ try {
116
+ offs.push(ctx.events.on('turn/end', (ev) => {
117
+ if (!getConfig().autoSnapshotEnabled) return;
118
+ const outcome = String((ev && (ev.outcome || ev.result?.outcome || ev.result?.status)) || '');
119
+ const sid = sessionIdOf(ev, ctx);
120
+ if (!sid) return;
121
+ if (outcome === 'success' || outcome === 'ok' || outcome === 'done') {
122
+ engine.pruneSnapshots(sid, 3).catch(() => {});
123
+ }
124
+ }));
125
+ } catch {}
126
+ return () => { for (const off of offs) try { typeof off === 'function' && off(); } catch {} };
127
+ }, 'dsh-time-machine: auto snapshot events fallback');
81
128
  }
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)
87
- try {
88
- if (ctx.events && typeof ctx.events.on === 'function') {
89
- ctx.effect(() => {
90
- const offs = [];
91
- try {
92
- offs.push(ctx.events.on('turn/start', (ev) => {
93
- const sid = sessionIdOf(ev, ctx);
94
- autoSnap(`auto:turn:${ev?.turnId || Date.now()}`, sid);
95
- }));
96
- } catch {}
97
- try {
98
- offs.push(ctx.events.on('approval/asked', (ev) => {
99
- const sid = sessionIdOf(ev, ctx);
100
- const tool = ev?.tool || ev?.name || 'approval';
101
- autoSnap(`auto:approval:${tool}`, sid);
102
- }));
103
- } catch {}
104
- try {
105
- offs.push(ctx.events.on('turn/end', (ev) => {
106
- if (!getConfig().autoSnapshotEnabled) return;
107
- const outcome = String((ev && (ev.outcome || ev.result?.outcome || ev.result?.status)) || '');
108
- const sid = sessionIdOf(ev, ctx);
109
- if (!sid) return;
110
- if (outcome === 'success' || outcome === 'ok' || outcome === 'done') {
111
- engine.pruneSnapshots(sid, 3).catch(() => {});
112
- }
113
- }));
114
- } catch {}
115
- return () => { for (const off of offs) try { typeof off === 'function' && off(); } catch {} };
116
- }, 'dsh-time-machine: auto snapshot events');
117
- }
118
- } catch {}
129
+ } catch {}
130
+ }
119
131
 
120
- // tools (session-aware)
132
+ // tools (session-aware and cwd-aware)
121
133
  const registerTools = (tctx) => {
122
134
  tctx.tools.register({
123
135
  name: 'time_machine_checkpoint_create',
@@ -127,11 +139,13 @@ export function apply(ctx, config) {
127
139
  properties: {
128
140
  label: { type: 'string', description: 'Human label for checkpoint' },
129
141
  sessionId: { type: 'string', description: 'Session id to scope checkpoint (auto-detected if omitted)' },
142
+ cwd: { type: 'string', description: 'Target workspace directory (auto-detected if omitted)' },
130
143
  },
131
144
  },
132
145
  execute: async (args = {}, execution) => {
133
- const sid = String(args.sessionId || sessionIdOf(execution, tctx) || sessionIdOf(args, tctx) || '').trim();
134
- const snap = await engine.createSnapshot(args.label, { sessionId: sid });
146
+ const sid = String(args.sessionId || sessionIdOf(execution, tctx) || '').trim();
147
+ const targetCwd = args.cwd ? String(args.cwd) : cwdOf(execution, tctx);
148
+ const snap = await engine.createSnapshot(args.label, { sessionId: sid, cwd: targetCwd });
135
149
  return { success: true, snapshot: snap };
136
150
  },
137
151
  });
@@ -160,28 +174,36 @@ export function apply(ctx, config) {
160
174
  properties: {
161
175
  id: { type: 'string', description: 'Snapshot id to rollback to' },
162
176
  confirm: { type: 'boolean', description: 'Must be true to confirm destructive rollback' },
177
+ cwd: { type: 'string', description: 'Target workspace directory' },
163
178
  },
164
179
  required: ['id', 'confirm'],
165
180
  },
166
- execute: async ({ id, confirm } = {}) => {
167
- const res = await engine.rollbackSnapshot(String(id ?? ''), { confirm });
181
+ execute: async ({ id, confirm, cwd } = {}, execution) => {
182
+ const targetCwd = cwd ? String(cwd) : cwdOf(execution, tctx);
183
+ const res = await engine.rollbackSnapshot(String(id ?? ''), { confirm, cwd: targetCwd });
168
184
  return { success: true, ...res };
169
185
  },
170
186
  });
171
187
 
172
188
  tctx.tools.register({
173
189
  name: 'time_machine_diff',
174
- description: 'Show diff between checkpoint and current or another checkpoint.',
190
+ description: 'Show unified diff or stat between checkpoint and current or another checkpoint.',
175
191
  parameters: {
176
192
  type: 'object',
177
193
  properties: {
178
194
  from: { type: 'string', description: 'Source snapshot id' },
179
195
  to: { type: 'string', description: 'Target snapshot id (default HEAD)' },
196
+ format: { type: 'string', enum: ['patch', 'stat'], description: 'Format of diff: patch (unified diff) or stat (summary)' },
197
+ cwd: { type: 'string', description: 'Target workspace directory' },
180
198
  },
181
199
  required: ['from'],
182
200
  },
183
- execute: async ({ from, to } = {}) => {
184
- const res = await engine.diff(String(from ?? ''), to ? String(to) : undefined);
201
+ execute: async ({ from, to, format, cwd } = {}, execution) => {
202
+ const targetCwd = cwd ? String(cwd) : cwdOf(execution, tctx);
203
+ const res = await engine.diff(String(from ?? ''), to ? String(to) : undefined, {
204
+ cwd: targetCwd,
205
+ format: format === 'stat' ? 'stat' : 'patch',
206
+ });
185
207
  return { success: true, ...res };
186
208
  },
187
209
  });
@@ -194,11 +216,13 @@ export function apply(ctx, config) {
194
216
  properties: {
195
217
  id: { type: 'string', description: 'Snapshot id to delete' },
196
218
  confirm: { type: 'boolean', description: 'Must be true to confirm deletion' },
219
+ cwd: { type: 'string', description: 'Target workspace directory' },
197
220
  },
198
221
  required: ['id', 'confirm'],
199
222
  },
200
- execute: async ({ id, confirm } = {}) => {
201
- const res = await engine.deleteSnapshot(String(id ?? ''), { confirm });
223
+ execute: async ({ id, confirm, cwd } = {}, execution) => {
224
+ const targetCwd = cwd ? String(cwd) : cwdOf(execution, tctx);
225
+ const res = await engine.deleteSnapshot(String(id ?? ''), { confirm, cwd: targetCwd });
202
226
  return { success: true, ...res };
203
227
  },
204
228
  });
@@ -211,11 +235,13 @@ export function apply(ctx, config) {
211
235
  properties: {
212
236
  sessionId: { type: 'string', description: 'Session id to prune (default current session)' },
213
237
  keep: { type: 'number', description: 'How many newest checkpoints to keep (default 3)' },
238
+ cwd: { type: 'string', description: 'Target workspace directory' },
214
239
  },
215
240
  },
216
241
  execute: async (args = {}, execution) => {
217
242
  const sid = String(args.sessionId || sessionIdOf(execution, tctx) || '').trim();
218
- const out = await engine.pruneSnapshots(sid, args.keep);
243
+ const targetCwd = args.cwd ? String(args.cwd) : cwdOf(execution, tctx);
244
+ const out = await engine.pruneSnapshots(sid, args.keep, { cwd: targetCwd });
219
245
  return { success: true, sessionId: sid, ...out };
220
246
  },
221
247
  });
@@ -253,7 +279,7 @@ export function apply(ctx, config) {
253
279
  });
254
280
  };
255
281
 
256
- // web routes for UI (session-aware via ?sessionId=)
282
+ // web routes for UI (session-aware via ?sessionId= and ?format=)
257
283
  ctx.effect(() => {
258
284
  const disposals = [];
259
285
  try {
@@ -275,8 +301,12 @@ export function apply(ctx, config) {
275
301
  const url = new URL(req.url, 'http://localhost');
276
302
  const from = url.searchParams.get('from') || '';
277
303
  const to = url.searchParams.get('to') || undefined;
304
+ const format = url.searchParams.get('format') || 'patch';
278
305
  try {
279
- const out = await engine.diff(String(from), to ? String(to) : undefined);
306
+ const out = await engine.diff(String(from), to ? String(to) : undefined, {
307
+ cwd: cwdOf(null, ctx),
308
+ format: format === 'stat' ? 'stat' : 'patch',
309
+ });
280
310
  res.setHeader('content-type', 'application/json');
281
311
  res.end(JSON.stringify({ success: true, ...out }));
282
312
  } catch (e) {
@@ -293,7 +323,8 @@ export function apply(ctx, config) {
293
323
  readJsonBody(req, res, async (parsed) => {
294
324
  try {
295
325
  const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '').trim();
296
- const snap = await engine.createSnapshot(parsed.label, { sessionId: sid });
326
+ const targetCwd = parsed.cwd ? String(parsed.cwd) : cwdOf(null, ctx);
327
+ const snap = await engine.createSnapshot(parsed.label, { sessionId: sid, cwd: targetCwd });
297
328
  res.setHeader('content-type', 'application/json');
298
329
  res.end(JSON.stringify({ success: true, snapshot: snap }));
299
330
  } catch (e) {
@@ -310,7 +341,8 @@ export function apply(ctx, config) {
310
341
  handler: (req, res) => {
311
342
  readJsonBody(req, res, async (parsed) => {
312
343
  try {
313
- const out = await engine.deleteSnapshot(String(parsed.id || ''), { confirm: parsed.confirm });
344
+ const targetCwd = parsed.cwd ? String(parsed.cwd) : cwdOf(null, ctx);
345
+ const out = await engine.deleteSnapshot(String(parsed.id || ''), { confirm: parsed.confirm, cwd: targetCwd });
314
346
  res.setHeader('content-type', 'application/json');
315
347
  res.end(JSON.stringify({ success: true, ...out }));
316
348
  } catch (e) {
@@ -328,7 +360,8 @@ export function apply(ctx, config) {
328
360
  readJsonBody(req, res, async (parsed) => {
329
361
  try {
330
362
  const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '');
331
- const out = await engine.pruneSnapshots(sid, parsed.keep);
363
+ const targetCwd = parsed.cwd ? String(parsed.cwd) : cwdOf(null, ctx);
364
+ const out = await engine.pruneSnapshots(sid, parsed.keep, { cwd: targetCwd });
332
365
  res.setHeader('content-type', 'application/json');
333
366
  res.end(JSON.stringify({ success: true, sessionId: sid, ...out }));
334
367
  } catch (e) {
@@ -345,7 +378,8 @@ export function apply(ctx, config) {
345
378
  handler: (req, res) => {
346
379
  readJsonBody(req, res, async (parsed) => {
347
380
  try {
348
- const out = await engine.rollbackSnapshot(String(parsed.id || ''), { confirm: parsed.confirm });
381
+ const targetCwd = parsed.cwd ? String(parsed.cwd) : cwdOf(null, ctx);
382
+ const out = await engine.rollbackSnapshot(String(parsed.id || ''), { confirm: parsed.confirm, cwd: targetCwd });
349
383
  res.setHeader('content-type', 'application/json');
350
384
  res.end(JSON.stringify({ success: true, ...out }));
351
385
  } catch (e) {
package/lib/snapshot.js CHANGED
@@ -18,159 +18,164 @@ async function defaultExec(cmd, args, opts = {}) {
18
18
  }
19
19
  }
20
20
 
21
+ const MAX_DIFF_LENGTH = 256 * 1024; // 256 KB safety limit
22
+
21
23
  // ponytail: in-memory engine, git shadow refs if repo present; O(n) scan, per-session trim
22
24
  export class ShadowSnapshotEngine {
23
25
  constructor({ exec = defaultExec, maxSnapshots = 20, cwd } = {}) {
24
26
  this.exec = exec;
25
27
  this.maxSnapshots = maxSnapshots;
26
28
  this.cwd = cwd;
27
- this.snapshots = []; // {id, label, sessionId, createdAt, commit, ref}
29
+ this.snapshots = []; // {id, label, sessionId, createdAt, commit, ref, treeHash}
28
30
  this.seq = 0;
31
+ this._queue = Promise.resolve();
29
32
  }
30
33
 
31
34
  _now() { return Date.now(); }
32
35
 
33
- async _trimFor(sessionId) {
34
- if (!sessionId) {
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
- }
41
- return;
42
- }
43
- const perSession = this.snapshots.filter(s => s.sessionId === sessionId);
44
- while (perSession.length > this.maxSnapshots) {
45
- const oldest = perSession.shift();
46
- const idx = this.snapshots.indexOf(oldest);
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
- }
53
- }
36
+ _enqueue(operation) {
37
+ const next = this._queue.then(() => operation(), () => operation());
38
+ this._queue = next.catch(() => {});
39
+ return next;
54
40
  }
55
41
 
56
- async _isGitRepo() {
57
- try {
58
- await this.exec('git', ['rev-parse', '--is-inside-work-tree'], this.cwd ? { cwd: this.cwd } : {});
59
- return true;
60
- } catch { return false; }
42
+ _resolveCwd(customCwd) {
43
+ return customCwd || this.cwd || process.cwd();
61
44
  }
62
45
 
63
- async _gitDir() {
46
+ async _hasGit(targetCwd) {
64
47
  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);
48
+ const cwd = this._resolveCwd(targetCwd);
49
+ const { stdout } = await this.exec('git', ['rev-parse', '--is-inside-work-tree'], { cwd });
50
+ return stdout.trim() === 'true';
69
51
  } catch {
70
- return null;
52
+ return false;
71
53
  }
72
54
  }
73
55
 
74
- // Restore checkpoint metadata from git shadow refs after a restart.
75
- // git is the durable store; memory is rebuilt from refs/dsh-time-machine/*.
76
- async loadFromRefs() {
77
- if (!(await this._isGitRepo())) return 0;
78
- let refs = [];
56
+ async cleanupOrphanedIndices(targetCwd) {
57
+ const cwd = this._resolveCwd(targetCwd);
79
58
  try {
80
- const { stdout } = await this.exec(
81
- 'git',
82
- ['for-each-ref', '--format=%(refname)%00%(objectname)', 'refs/dsh-time-machine/'],
83
- this.cwd ? { cwd: this.cwd } : {},
84
- );
85
- refs = String(stdout ?? '').trim().split('\n').filter(Boolean);
86
- } catch { return 0; }
87
- const restored = [];
88
- for (const line of refs) {
89
- const [ref, commit] = line.split('\0');
90
- if (!ref || !commit) continue;
91
- if (this.snapshots.some(s => s.ref === ref)) continue;
92
- let createdAt = 0;
93
- let label = ref.split('/').pop();
94
- try {
95
- const { stdout } = await this.exec(
96
- 'git',
97
- ['log', '-1', '--format=%ct%00%s', ref],
98
- this.cwd ? { cwd: this.cwd } : {},
99
- );
100
- const [ct, msg] = String(stdout ?? '').split('\0');
101
- createdAt = Number(ct) * 1000 || 0;
102
- if (msg && String(msg).trim()) label = String(msg).trim();
103
- } catch {}
104
- const short = ref.replace(/^refs\/dsh-time-machine\//, '');
105
- const slash = short.indexOf('/');
106
- const sessionId = slash === -1 ? '' : short.slice(0, slash);
107
- const id = slash === -1 ? short : short.slice(slash + 1);
108
- restored.push({ id: id || short, label, sessionId, createdAt, commit, ref, seq: 0 });
59
+ const { stdout } = await this.exec('git', ['rev-parse', '--git-dir'], { cwd });
60
+ const gitDir = stdout.trim();
61
+ const resolvedGitDir = path.isAbsolute(gitDir) ? gitDir : path.resolve(cwd, gitDir);
62
+ const files = await fs.readdir(resolvedGitDir).catch(() => []);
63
+ for (const file of files) {
64
+ if (file.startsWith('tm_index_')) {
65
+ await fs.unlink(path.join(resolvedGitDir, file)).catch(() => {});
66
+ }
67
+ }
68
+ } catch (e) {
69
+ console.warn('[dsh-time-machine] cleanupOrphanedIndices warning:', e.message || e);
109
70
  }
110
- // stable order: oldest first, tie-break by ref name
111
- restored.sort((a, b) => a.createdAt - b.createdAt || (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0));
112
- for (const snap of restored) snap.seq = ++this.seq;
113
- this.snapshots.push(...restored);
114
- const sessions = [...new Set(this.snapshots.map(s => s.sessionId))];
115
- for (const sid of sessions) await this._trimFor(sid);
116
- if (sessions.length === 0) {
71
+ }
72
+
73
+ async _trimFor(sessionId, targetCwd) {
74
+ const cwd = this._resolveCwd(targetCwd);
75
+ if (!sessionId) {
117
76
  while (this.snapshots.length > this.maxSnapshots) {
118
77
  const oldest = this.snapshots.shift();
119
78
  if (oldest?.ref) {
120
- try { await this.exec('git', ['update-ref', '-d', oldest.ref], this.cwd ? { cwd: this.cwd } : {}); } catch {}
79
+ try {
80
+ await this.exec('git', ['update-ref', '-d', oldest.ref], { cwd });
81
+ } catch (e) {
82
+ console.warn('[dsh-time-machine] failed to delete evicted ref:', oldest.ref, e.message || e);
83
+ }
121
84
  }
122
85
  }
86
+ return;
87
+ }
88
+ const sessionSnaps = this.snapshots.filter(s => s.sessionId === sessionId);
89
+ const toRemoveCount = sessionSnaps.length - this.maxSnapshots;
90
+ if (toRemoveCount > 0) {
91
+ const toRemove = sessionSnaps.slice(0, toRemoveCount);
92
+ const removeIds = new Set(toRemove.map(s => s.id));
93
+ for (const snap of toRemove) {
94
+ if (snap.ref) {
95
+ try {
96
+ await this.exec('git', ['update-ref', '-d', snap.ref], { cwd });
97
+ } catch (e) {
98
+ console.warn('[dsh-time-machine] failed to delete session ref:', snap.ref, e.message || e);
99
+ }
100
+ }
101
+ }
102
+ this.snapshots = this.snapshots.filter(s => !removeIds.has(s.id));
123
103
  }
124
- return restored.length;
125
104
  }
126
105
 
127
- async createSnapshot(label = '', { sessionId = '' } = {}) {
128
- const id = crypto.randomUUID().slice(0, 8);
129
- const createdAt = this._now();
130
- const safeLabel = String(label ?? '').trim() || `checkpoint-${id}`;
131
- const rawSid = String(sessionId || '').trim();
132
- const safeSid = rawSid ? rawSid.replace(/[^a-zA-Z0-9_-]/g, '_') : '';
106
+ createSnapshot(label, { sessionId = '', cwd: customCwd } = {}) {
107
+ return this._enqueue(() => this._doCreateSnapshot(label, { sessionId, cwd: customCwd }));
108
+ }
109
+
110
+ async _doCreateSnapshot(label, { sessionId = '', cwd: customCwd } = {}) {
111
+ const cwd = this._resolveCwd(customCwd);
112
+ const id = `snap-${this._now()}-${crypto.randomBytes(3).toString('hex')}`;
113
+ const cleanLabel = String(label || 'checkpoint').slice(0, 100);
114
+ const cleanSession = String(sessionId || '').trim();
133
115
  let commit = null;
134
116
  let ref = null;
135
- const inGit = await this._isGitRepo();
136
- if (inGit) {
137
- let shadowIndex = null;
117
+ let treeHash = null;
118
+
119
+ if (await this._hasGit(cwd)) {
120
+ const gitDirRes = await this.exec('git', ['rev-parse', '--git-dir'], { cwd });
121
+ const gitDir = gitDirRes.stdout.trim();
122
+ const resolvedGitDir = path.isAbsolute(gitDir) ? gitDir : path.resolve(cwd, gitDir);
123
+ const tempIndexFile = path.join(resolvedGitDir, `tm_index_${crypto.randomBytes(4).toString('hex')}`);
124
+
125
+ const gitEnv = {
126
+ ...process.env,
127
+ GIT_INDEX_FILE: tempIndexFile,
128
+ GIT_AUTHOR_NAME: 'DSH Time Machine',
129
+ GIT_AUTHOR_EMAIL: 'time-machine@deepseek.local',
130
+ GIT_COMMITTER_NAME: 'DSH Time Machine',
131
+ GIT_COMMITTER_EMAIL: 'time-machine@deepseek.local',
132
+ };
133
+
138
134
  try {
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;
135
+ await this.exec('git', ['add', '-A'], { cwd, env: gitEnv });
136
+ const treeRes = await this.exec('git', ['write-tree'], { cwd, env: gitEnv });
137
+ treeHash = treeRes.stdout.trim();
138
+
139
+ // Check if identical to last snapshot for this session to avoid redundant commits
140
+ const lastSnap = this.snapshots.filter(s => s.sessionId === cleanSession).slice(-1)[0];
141
+ if (lastSnap && lastSnap.treeHash && lastSnap.treeHash === treeHash) {
142
+ return lastSnap;
152
143
  }
153
144
 
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);
157
- const treeHash = String(tree).trim();
158
- const { stdout: commitHash } = await this.exec('git', ['commit-tree', treeHash, '-m', safeLabel], shadowOpts);
159
- commit = String(commitHash).trim();
160
- ref = safeSid ? `refs/dsh-time-machine/${safeSid}/${id}` : `refs/dsh-time-machine/${id}`;
161
- await this.exec('git', ['update-ref', ref, commit], baseOpts);
162
- } catch {
163
- commit = null;
164
- ref = null;
165
- } finally {
166
- if (shadowIndex) {
167
- try { await fs.unlink(shadowIndex); } catch {}
145
+ const headCommitRes = await this.exec('git', ['rev-parse', '--verify', 'HEAD'], { cwd }).catch(() => ({ stdout: '' }));
146
+ const headCommit = headCommitRes.stdout.trim();
147
+
148
+ const commitArgs = ['commit-tree', treeHash, '-m', `[dsh-time-machine] ${cleanLabel}`];
149
+ if (headCommit) {
150
+ commitArgs.push('-p', headCommit);
168
151
  }
152
+ const commitRes = await this.exec('git', commitArgs, { cwd, env: gitEnv });
153
+ commit = commitRes.stdout.trim();
154
+
155
+ const refPath = cleanSession
156
+ ? `refs/dsh-time-machine/${cleanSession}/${id}`
157
+ : `refs/dsh-time-machine/${id}`;
158
+ await this.exec('git', ['update-ref', refPath, commit], { cwd });
159
+ ref = refPath;
160
+ } catch (e) {
161
+ console.warn('[dsh-time-machine] git snapshot creation failed, falling back to memory record:', e.message || e);
162
+ } finally {
163
+ await fs.unlink(tempIndexFile).catch(() => {});
169
164
  }
170
165
  }
171
- const snap = { id, label: safeLabel, sessionId: rawSid, createdAt, commit, ref, seq: ++this.seq };
166
+
167
+ const snap = {
168
+ id,
169
+ label: cleanLabel,
170
+ sessionId: cleanSession,
171
+ createdAt: this._now(),
172
+ commit,
173
+ ref,
174
+ treeHash,
175
+ seq: ++this.seq,
176
+ };
172
177
  this.snapshots.push(snap);
173
- await this._trimFor(rawSid);
178
+ await this._trimFor(cleanSession, cwd);
174
179
  return snap;
175
180
  }
176
181
 
@@ -184,111 +189,177 @@ export class ShadowSnapshotEngine {
184
189
  return this.snapshots.find(s => s.id === id) || null;
185
190
  }
186
191
 
187
- async rollbackSnapshot(id, { confirm } = {}) {
188
- if (confirm !== true) {
189
- const err = new Error('confirm:true required to rollback');
190
- err.code = 'CONFIRM_REQUIRED';
191
- throw err;
192
- }
192
+ deleteSnapshot(id, { confirm = false, cwd: customCwd } = {}) {
193
+ return this._enqueue(async () => {
194
+ if (!confirm) throw new Error('confirm required to delete snapshot');
195
+ const idx = this.snapshots.findIndex(s => s.id === id);
196
+ if (idx === -1) throw new Error(`Snapshot not found: ${id}`);
197
+ const snap = this.snapshots[idx];
198
+ const cwd = this._resolveCwd(customCwd);
199
+ if (snap.ref && await this._hasGit(cwd)) {
200
+ try {
201
+ await this.exec('git', ['update-ref', '-d', snap.ref], { cwd });
202
+ } catch (e) {
203
+ console.warn('[dsh-time-machine] failed to delete ref on deleteSnapshot:', snap.ref, e.message || e);
204
+ }
205
+ }
206
+ this.snapshots.splice(idx, 1);
207
+ return { deleted: true, id };
208
+ });
209
+ }
210
+
211
+ pruneSnapshots(sessionId = '', keep = 3, { cwd: customCwd } = {}) {
212
+ return this._enqueue(async () => {
213
+ const sid = sessionId != null ? String(sessionId).trim() : '';
214
+ const list = this.listSnapshots(sid); // newest first
215
+ const minKeep = Math.max(0, Number.isFinite(Number(keep)) ? Number(keep) : 3);
216
+ const toRemove = list.slice(minKeep);
217
+ const removed = [];
218
+ const cwd = this._resolveCwd(customCwd);
219
+ for (const snap of toRemove) {
220
+ const idx = this.snapshots.findIndex(s => s.id === snap.id);
221
+ if (idx !== -1) {
222
+ if (snap.ref && await this._hasGit(cwd)) {
223
+ try {
224
+ await this.exec('git', ['update-ref', '-d', snap.ref], { cwd });
225
+ } catch (e) {
226
+ console.warn('[dsh-time-machine] failed to delete ref on pruneSnapshots:', snap.ref, e.message || e);
227
+ }
228
+ }
229
+ this.snapshots.splice(idx, 1);
230
+ removed.push(snap.id);
231
+ }
232
+ }
233
+ return { removed, kept: list.length - removed.length };
234
+ });
235
+ }
236
+
237
+ rollbackSnapshot(id, { confirm = false, cwd: customCwd } = {}) {
238
+ return this._enqueue(() => this._doRollbackSnapshot(id, { confirm, cwd: customCwd }));
239
+ }
240
+
241
+ async _doRollbackSnapshot(id, { confirm = false, cwd: customCwd } = {}) {
242
+ if (!confirm) throw new Error('confirm required for rollback');
193
243
  const snap = this.getSnapshot(id);
194
- if (!snap) {
195
- const err = new Error(`snapshot ${id} not found`);
196
- err.code = 'NOT_FOUND';
197
- throw err;
198
- }
199
- const inGit = await this._isGitRepo();
200
- if (inGit && snap.commit) {
201
- const opts = this.cwd ? { cwd: this.cwd } : {};
244
+ if (!snap) throw new Error(`Snapshot not found: ${id}`);
245
+ const cwd = this._resolveCwd(customCwd);
246
+ if (snap.commit && await this._hasGit(cwd)) {
202
247
  try {
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);
248
+ await this.exec('git', ['read-tree', snap.commit], { cwd });
249
+ await this.exec('git', ['checkout-index', '-a', '-f'], { cwd });
250
+ await this.exec('git', ['clean', '-fd'], { cwd });
207
251
  } catch (e) {
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(() => {});
252
+ console.warn('[dsh-time-machine] rollback error:', e.message || e);
253
+ throw new Error(`Rollback execution failed: ${e.message || e}`);
211
254
  }
212
255
  }
213
- return { rolledBack: true, snapshot: snap };
256
+ return { rolledBack: true, id, commit: snap.commit };
214
257
  }
215
258
 
216
- async deleteSnapshot(id, { confirm } = {}) {
217
- if (confirm !== true) {
218
- const err = new Error('confirm:true required to delete');
219
- err.code = 'CONFIRM_REQUIRED';
220
- throw err;
221
- }
222
- const snap = this.getSnapshot(id);
223
- if (!snap) {
224
- const err = new Error(`snapshot ${id} not found`);
225
- err.code = 'NOT_FOUND';
226
- throw err;
227
- }
228
- if (snap.ref) {
229
- try {
230
- await this.exec('git', ['update-ref', '-d', snap.ref], this.cwd ? { cwd: this.cwd } : {});
231
- } catch {}
259
+ async diff(fromId, toId = undefined, { cwd: customCwd, format = 'patch' } = {}) {
260
+ const snapFrom = this.getSnapshot(fromId);
261
+ if (!snapFrom) throw new Error(`Snapshot not found: ${fromId}`);
262
+ const snapTo = toId ? this.getSnapshot(toId) : null;
263
+ if (toId && !snapTo) throw new Error(`Target snapshot not found: ${toId}`);
264
+
265
+ const cwd = this._resolveCwd(customCwd);
266
+ if (!(await this._hasGit(cwd)) || !snapFrom.commit) {
267
+ const toLabel = snapTo ? snapTo.label : "current";
268
+ return { from: fromId, to: toId || "HEAD", diff: `diff ${snapFrom.label} -> ${toLabel} (no git repo)`, format };
232
269
  }
233
- const idx = this.snapshots.indexOf(snap);
234
- if (idx !== -1) this.snapshots.splice(idx, 1);
235
- return { deleted: true, snapshot: snap };
236
- }
237
270
 
238
- async pruneSnapshots(sessionId, keep = 3) {
239
- const sid = sessionId != null ? String(sessionId).trim() : undefined;
240
- const list = this.listSnapshots(sid); // newest first
241
- const minKeep = Math.max(0, Number.isFinite(Number(keep)) ? Number(keep) : 3);
242
- const toRemove = list.slice(minKeep);
243
- const removed = [];
244
- for (const snap of toRemove) {
245
- try { await this.deleteSnapshot(snap.id, { confirm: true }); removed.push(snap.id); } catch {}
271
+ try {
272
+ const fromRef = snapFrom.commit || snapFrom.ref;
273
+ let diffArgs = ['diff'];
274
+ if (format === 'stat') {
275
+ diffArgs.push('--stat');
276
+ }
277
+
278
+ if (snapTo) {
279
+ const toRef = snapTo.commit || snapTo.ref;
280
+ diffArgs.push(fromRef, toRef);
281
+ } else {
282
+ diffArgs.push(fromRef);
283
+ }
284
+
285
+ const { stdout } = await this.exec('git', diffArgs, { cwd });
286
+ let diffOutput = stdout;
287
+ let truncated = false;
288
+ if (diffOutput.length > MAX_DIFF_LENGTH) {
289
+ diffOutput = diffOutput.slice(0, MAX_DIFF_LENGTH) + '\n\n... [diff truncated: output exceeded limit]';
290
+ truncated = true;
291
+ }
292
+ return { diff: diffOutput, from: fromId, to: toId || 'HEAD', format, truncated };
293
+ } catch (e) {
294
+ console.warn('[dsh-time-machine] diff computation error:', e.message || e);
295
+ return { diff: `[diff error: ${e.message || e}]`, from: fromId, to: toId || 'HEAD', format };
246
296
  }
247
- return { removed, kept: list.length - removed.length };
248
297
  }
249
298
 
250
- async diff(fromId, toId) {
251
- const from = this.getSnapshot(fromId);
252
- if (!from) {
253
- const err = new Error(`snapshot ${fromId} not found`);
254
- err.code = 'NOT_FOUND';
255
- throw err;
256
- }
257
- let to = toId ? this.getSnapshot(toId) : null;
258
- const inGit = await this._isGitRepo();
259
- if (inGit && from.commit) {
260
- const opts = this.cwd ? { cwd: this.cwd } : {};
261
- try {
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);
266
- const diffStat = String(stdout ?? '').trim();
267
- if (diffStat) return { from: fromId, to: toId || 'HEAD', diff: diffStat };
268
- } catch {}
269
- try {
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);
274
- return { from: fromId, to: toId || 'HEAD', diff: String(stdout ?? '').trim() || '(no changes)' };
275
- } catch {}
299
+ async loadFromRefs(targetCwd) {
300
+ const cwd = this._resolveCwd(targetCwd);
301
+ if (!(await this._hasGit(cwd))) return 0;
302
+ let refs = [];
303
+ try {
304
+ // Support both single-batch with committerdate/subject and basic for-each-ref
305
+ const { stdout } = await this.exec('git', [
306
+ 'for-each-ref',
307
+ '--format=%(refname)%00%(objectname)%00%(committerdate:raw)%00%(contents:subject)',
308
+ 'refs/dsh-time-machine'
309
+ ], { cwd });
310
+ refs = String(stdout ?? '').trim().split('\n').filter(Boolean);
311
+ } catch {
312
+ return 0;
276
313
  }
277
- const toLabel = to ? to.label : 'current';
278
- return { from: fromId, to: toId || 'HEAD', diff: `diff ${from.label} -> ${toLabel} (no git repo)` };
279
- }
280
314
 
281
- setMax(n) {
282
- this.maxSnapshots = Math.max(1, Number(n) || 20);
283
- const sessions = [...new Set(this.snapshots.map(s => s.sessionId))];
284
- for (const sid of sessions) this._trimFor(sid).catch(() => {});
285
- if (sessions.length === 0) {
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
- }
315
+ let restoredCount = 0;
316
+ for (const line of refs) {
317
+ const parts = line.split('\0');
318
+ const ref = parts[0]?.trim();
319
+ const commit = parts[1]?.trim();
320
+ if (!ref || !commit) continue;
321
+ if (this.snapshots.some(s => s.ref === ref)) continue;
322
+
323
+ let createdAt = 0;
324
+ let label = ref.split('/').pop();
325
+
326
+ // If parts has date and subject from single-batch
327
+ if (parts.length >= 4 && parts[2] && parts[3] !== undefined) {
328
+ const timestampSeconds = parseInt(parts[2].split(' ')[0], 10);
329
+ if (!isNaN(timestampSeconds)) createdAt = timestampSeconds * 1000;
330
+ const sub = parts[3].trim().replace(/^\[dsh-time-machine\]\s*/, '');
331
+ if (sub) label = sub;
332
+ } else {
333
+ // Fallback for tests or git versions returning minimal for-each-ref
334
+ try {
335
+ const { stdout } = await this.exec('git', ['log', '-1', '--format=%ct%00%s', ref], { cwd });
336
+ const [ct, msg] = String(stdout ?? '').split('\0');
337
+ createdAt = Number(ct) * 1000 || 0;
338
+ if (msg && String(msg).trim()) label = String(msg).trim();
339
+ } catch {}
291
340
  }
341
+
342
+ const short = ref.replace(/^refs\/dsh-time-machine\//, '');
343
+ const slash = short.indexOf('/');
344
+ const sessionId = slash === -1 ? '' : short.slice(0, slash);
345
+ const id = slash === -1 ? short : short.slice(slash + 1);
346
+
347
+ this.snapshots.push({
348
+ id: id || short,
349
+ label,
350
+ sessionId,
351
+ createdAt,
352
+ commit,
353
+ ref,
354
+ seq: ++this.seq,
355
+ });
356
+ restoredCount++;
292
357
  }
358
+
359
+ this.snapshots.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0) || a.seq - b.seq);
360
+ const sessions = [...new Set(this.snapshots.map(s => s.sessionId))];
361
+ for (const sid of sessions) await this._trimFor(sid, cwd);
362
+ if (sessions.length === 0) await this._trimFor('', cwd);
363
+ return restoredCount;
293
364
  }
294
365
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-time-machine",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "DSH plugin for smart checkpoints, workspace safety guards, and instant rollback",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -47,7 +47,6 @@
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@deepseek-ai/cordis": "^4.0.1",
50
- "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
51
50
  "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
52
51
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
53
52
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",