@diffin/cli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/capture.mjs CHANGED
@@ -24,7 +24,7 @@ import { putFile } from './backfill.mjs';
24
24
  import { machineHash } from './machine.mjs';
25
25
  import { detectAgent, agentById } from './agents.mjs';
26
26
  import { ensureInit } from './init.mjs';
27
- import { ALL_LOOKBACK_MS, allowFolders, allowedFolders, allowedSince, excludeFolders, excludedFolders, heldBy, ignoredBy, includeFolder, topFolders, withoutIgnored } from './ignore.mjs';
27
+ import { ALL_LOOKBACK_MS, allowFolders, allowedFolders, excludeFolders, excludedFolders, folderCensus, heldBy, heldFolders, ignoredBy, includeFolder, topFolders, withoutIgnored } from './ignore.mjs';
28
28
  import { homedir } from 'node:os';
29
29
 
30
30
  const ROOT = join(process.env.DIFFIN_HOME ?? homedir(), '.claude', 'projects');
@@ -277,8 +277,8 @@ export async function planAll(ad, floors, to, entries) {
277
277
  * Candidate sessions: moved recently, and not in a folder the person excluded (ignore.mjs — the folder it began in
278
278
  * or the one it is in now, either of them under a `.diffin-ignore`).
279
279
  */
280
- export async function candidates(ad, now = Date.now(), { hold = false } = {}) {
281
- return withoutIgnored(ad.enumerate({ from: new Date(now - ALL_LOOKBACK_MS).toISOString() }), { hold });
280
+ export async function candidates(ad, now = Date.now()) {
281
+ return withoutIgnored(ad.enumerate({ from: new Date(now - ALL_LOOKBACK_MS).toISOString() }));
282
282
  }
283
283
 
284
284
  /**
@@ -289,8 +289,10 @@ export async function candidates(ad, now = Date.now(), { hold = false } = {}) {
289
289
  async function seedAllowed() {
290
290
  if (allowedFolders() !== null) return;
291
291
  const dirs = [];
292
- // both folders of a session — where it began and where it is — or a session that moved would be held by the seed it made
293
- for (const ad of allAdapters()) { try { const { kept } = await candidates(ad); dirs.push(...kept.flatMap((e) => [e.cwd, e.cwd0]).filter(Boolean)); } catch { /* an unreadable agent seeds nothing */ } }
292
+ // every folder a turn of these sessions was typed in — what the capture would have sent before the list existed, now
293
+ // that a turn is held by its own folder (2026-10-12; before, the two ends of each session)
294
+ const now = Date.now();
295
+ for (const ad of allAdapters()) { try { const { kept } = await candidates(ad, now); dirs.push(...(await folderCensus(kept, { tsOf: ad.tsOf, fromMs: now - ALL_LOOKBACK_MS })).keys()); } catch { /* an unreadable agent seeds nothing */ } }
294
296
  if (dirs.length) allowFolders(topFolders(dirs));
295
297
  }
296
298
 
@@ -304,10 +306,10 @@ const unscrub = (p) => (p === '~' ? homedir() : p.startsWith('~/') ? join(homedi
304
306
  * decision's own time — only the work after it goes up. Nothing here may stop a capture: a server that cannot be
305
307
  * reached, or an older server without the route, leaves the lists as they are.
306
308
  */
307
- async function syncFolders(token, heldAll) {
309
+ async function syncFolders(token, heldDirs) {
308
310
  const allowed = allowedFolders();
309
311
  if (allowed === null) return { changed: false };
310
- const held = topFolders(heldAll.map((h) => h.folder).filter(Boolean)).map((f) => posixPath(scrub(f)));
312
+ const held = topFolders(heldDirs).map((f) => posixPath(scrub(f)));
311
313
  let body = null;
312
314
  try {
313
315
  const res = await fetchRetry(`${SERVER}/folders/held`, () => ({ method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ device: deviceId(), held, allowed: allowed.map((f) => posixPath(scrub(f))), excluded: excludedFolders().map((f) => posixPath(scrub(f))) }) }), 1);
@@ -328,8 +330,8 @@ async function syncFolders(token, heldAll) {
328
330
  }
329
331
 
330
332
  /** the one line about folders waiting for the person — the agent relays it, the person acts on a terminal */
331
- function sayHeld(held) {
332
- const folders = topFolders(held.map((h) => h.folder).filter(Boolean));
333
+ function sayHeld(heldDirs) {
334
+ const folders = topFolders(heldDirs);
333
335
  if (!folders.length) return;
334
336
  say(marks.wait(), `새 폴더 ${folders.length}곳은 아직 기록하지 않습니다 · ${folders.map((f) => posixPath(scrub(f))).join(' · ')}`,
335
337
  '허용하려면 터미널에서 diffin allow <폴더> 또는 diffin folders (사람이 직접 실행합니다)');
@@ -353,13 +355,15 @@ async function captureAll() {
353
355
  // sent one agent's sessions kept the others' waiting for their own agent to ask after the gap — with three agents
354
356
  // on a machine, most of a day's work. The run is the asker's; a session of another agent names its own.
355
357
  await seedAllowed();
358
+ // the folders waiting are those of the turns typed in the lookback in a folder not allowed — the whole lookback, not
359
+ // only since the last capture: the server REPLACES its pending list with each report, so a folder reported once and
360
+ // left out of the next would vanish from the app before the person decided on it
356
361
  const gather = async () => {
357
362
  const found = [], heldAll = [];
358
363
  for (const ad of allAdapters()) {
359
- let kept = [], held = [];
360
- try { ({ kept, held } = await candidates(ad, Date.now(), { hold: true })); } catch { /* an agent whose sessions cannot be read holds nobody else back */ }
364
+ let kept = [];
365
+ try { ({ kept } = await candidates(ad, Date.now())); heldAll.push(...(await heldFolders(kept, { tsOf: ad.tsOf, fromMs: Date.now() - ALL_LOOKBACK_MS })).keys()); } catch { /* an agent whose sessions cannot be read holds nobody else back */ }
361
366
  for (const e of kept) found.push({ ad, e });
362
- heldAll.push(...held);
363
367
  }
364
368
  return { found, heldAll };
365
369
  };
@@ -367,13 +371,13 @@ async function captureAll() {
367
371
  // the app may have decided on a held folder since the last capture: apply, then look again with the lists as they are now
368
372
  const { changed } = await syncFolders(token, heldAll);
369
373
  if (changed) ({ found, heldAll } = await gather());
370
- // a session of a folder the person allowed after the fact goes up from that moment: the later of its two folders' times
371
- const sinceOf = (e) => { const ts = [e.cwd, e.cwd0].map((c) => allowedSince(c)).filter(Boolean).sort(); return ts.length ? ts[ts.length - 1] : null; };
374
+ // a folder the person allowed after the fact goes up from that moment — judged per turn as the rows are read
375
+ // (`turnHeld`), so no session-wide `since` is sent any more: one would floor the session's other folders too
372
376
  const offered = newestFirst(found);
373
377
  if (!offered.length) { say(marks.wait(), '최근 움직인 세션이 없습니다.'); sayHeld(heldAll); return; }
374
378
  const res = await fetchRetry(`${SERVER}/backfill/runs`, () => ({
375
379
  method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
376
- body: JSON.stringify({ source, device: deviceId(), ...(machineHash() ? { computer: machineHash() } : {}), capture: { all: true, sessions: offered.map(({ ad, e }) => ({ id: e.sid, ...(e.last ? { last: new Date(e.last).toISOString() } : {}), ...(sinceOf(e) ? { since: sinceOf(e) } : {}), ...(ad.name !== source ? { source: ad.name } : {}) })) } }),
380
+ body: JSON.stringify({ source, device: deviceId(), ...(machineHash() ? { computer: machineHash() } : {}), capture: { all: true, sessions: offered.map(({ ad, e }) => ({ id: e.sid, ...(e.last ? { last: new Date(e.last).toISOString() } : {}), ...(ad.name !== source ? { source: ad.name } : {}) })) } }),
377
381
  }));
378
382
  if (!res.ok) { await refused(res); return; }
379
383
  const { runId, machine, to, floors, resumed, done } = await res.json();
package/lib/folders.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  // result line names the held folders and these two commands; the agent relays the line, the person types.
7
7
  import { resolve as resolvePath } from 'node:path';
8
8
  import { allAdapters } from './sources/index.mjs';
9
- import { ALL_LOOKBACK_MS, allowFolders, allowedFolders, allowedFrom, excludeFolders, excludedFrom, folderCensus, folderTree, heldBy, ignoreFileFor, includeFolder, topFolders, withoutIgnored } from './ignore.mjs';
9
+ import { ALL_LOOKBACK_MS, allowFolders, allowedFolders, allowedFrom, excludeFolders, excludedFrom, folderCensus, folderTree, heldBy, heldFolders, ignoreFileFor, includeFolder, topFolders, withoutIgnored } from './ignore.mjs';
10
10
  import { posixPath, scrub } from './scrub.mjs';
11
11
  import { dim, fail, ok, out, selectMany, spin, stepLive } from './ui.mjs';
12
12
 
@@ -20,9 +20,9 @@ async function recent() {
20
20
  const jobs = [], heldDirs = [];
21
21
  for (const ad of allAdapters()) {
22
22
  try {
23
- const { kept, held } = await withoutIgnored(ad.enumerate({ from: new Date(fromMs).toISOString() }), { hold: true });
24
- jobs.push({ ad, entries: [...kept, ...held.map((h) => h.entry)], cutOpts: { tsOf: ad.tsOf, fromMs, toMs: now } });
25
- heldDirs.push(...held.map((h) => h.folder));
23
+ const { kept } = await withoutIgnored(ad.enumerate({ from: new Date(fromMs).toISOString() }));
24
+ jobs.push({ ad, entries: kept, cutOpts: { tsOf: ad.tsOf, fromMs, toMs: now } });
25
+ heldDirs.push(...(await heldFolders(kept, { tsOf: ad.tsOf, fromMs })).keys());
26
26
  } catch { /* an agent whose sessions cannot be read is left out */ }
27
27
  }
28
28
  return { jobs, held: topFolders(heldDirs) };
package/lib/ignore.mjs CHANGED
@@ -81,7 +81,7 @@ export function topFolders(folders) {
81
81
  }
82
82
  /**
83
83
  * Allow folders. `at` is the moment the PERSON allowed them (a terminal command, the app's decision): only the work after
84
- * it goes up (`allowedSince`, sent as a capture session's `since`). The onboarding list and the seed pass no time — those
84
+ * it goes up — each turn made in that folder is judged against it (`turnHeld`). The onboarding list and the seed pass no time — those
85
85
  * folders' history is the backfill's and the watermark's, as before.
86
86
  */
87
87
  export function allowFolders(folders, at) {
@@ -149,12 +149,9 @@ export async function folderCensus(entries, { tsOf, fromMs = 0, toMs = Infinity
149
149
  const turns = new Map();
150
150
  for (const e of entries) {
151
151
  for await (const r of e.files[0].rows()) {
152
- if (r?.type !== 'user' || r.isMeta) continue;
152
+ if (!isSaid(r)) continue;
153
153
  const t = tsOf(r);
154
154
  if (t && (t < fromMs || t >= toMs)) continue;
155
- const c = r.message?.content;
156
- const said = typeof c === 'string' ? c.trim() : Array.isArray(c) && !c.some((b) => b?.type === 'tool_result') && c.some((b) => b?.type === 'text' && String(b.text ?? '').trim());
157
- if (!said) continue;
158
155
  const dir = String(r.cwd ?? e.cwd ?? '');
159
156
  if (!dir || ignoredBy(dir)) continue;
160
157
  turns.set(dir, (turns.get(dir) ?? 0) + 1);
@@ -190,19 +187,62 @@ export function excludedFrom(tree, on) {
190
187
  }
191
188
 
192
189
  /**
193
- * Split a list of sessions into the ones that may be sent, how many a `.diffin-ignore` held back, and — with `hold` —
194
- * the ones waiting for the person to allow their folder (`held`: the session and the folder it is in). A session is
195
- * held when the folder it began in OR the one it is in now is held, as it is excluded by either.
190
+ * Split a list of sessions into the ones that may be sent and how many a `.diffin-ignore` or an exclusion held back
191
+ * (by the folder a session began in OR is in now — an exclusion stays session-wide, the stricter reading).
192
+ *
193
+ * Waiting for an allowance is NOT decided here any more (2026-10-12): it is decided per TURN, by the folder the person
194
+ * spoke in (`turnHeld`, applied as rows are read in stream.mjs). Judged per session, a session that began in a folder
195
+ * since renamed was held for good (the front session: 183 turns under the old name, 722 under the allowed one), and a
196
+ * session that began and ended in an allowed folder sent the turns it made in another project in between (miner-server:
197
+ * 281 turns in two other apps; this repository's oldest session: 74 in two folders never allowed).
196
198
  */
197
- export async function withoutIgnored(entries, { hold = false } = {}) {
198
- const kept = [], held = [];
199
+ export async function withoutIgnored(entries) {
200
+ const kept = [];
199
201
  let ignored = 0;
200
202
  for (const e of entries) {
201
203
  const f = await sessionFolders(e);
202
204
  if (f.ignored) { ignored++; continue; }
203
205
  const cwd = f.last ?? f.first ?? e.cwd;
204
- if (hold && (heldBy(f.first) || heldBy(f.last))) { held.push({ entry: { ...e, cwd }, folder: heldBy(f.last) ? f.last : f.first }); continue; }
205
206
  kept.push({ ...e, cwd, cwd0: f.first ?? cwd });
206
207
  }
207
- return { kept, ignored, held };
208
+ return { kept, ignored };
209
+ }
210
+
211
+ /**
212
+ * Whether a turn the person made in `dir` at `ms` waits (is not sent): its folder is not allowed on a machine that has a
213
+ * list, or it was made before the person allowed that folder. A machine with no list holds nothing — it sends as before.
214
+ */
215
+ export function turnHeld(dir, ms) {
216
+ if (!dir || allowedFolders() === null) return false;
217
+ if (heldBy(dir)) return true;
218
+ const since = allowedSince(dir);
219
+ return Boolean(since && ms && ms < Date.parse(since));
220
+ }
221
+
222
+ /** a row that is a turn the person typed — not a tool result, not a meta line — the unit a folder is judged by */
223
+ export function isSaid(r) {
224
+ if (r?.type !== 'user' || r.isMeta) return false;
225
+ const c = r.message?.content;
226
+ return typeof c === 'string' ? Boolean(c.trim()) : Array.isArray(c) && !c.some((b) => b?.type === 'tool_result') && c.some((b) => b?.type === 'text' && String(b.text ?? '').trim());
227
+ }
228
+
229
+ /**
230
+ * The folders of the turns that are waiting — made at or after `fromMs` in a folder the person has not allowed — with how
231
+ * many. What a capture reports to the app and names in its result line, and what `diffin folders` puts first: the folders
232
+ * a person actually spoke in, never the name a session began under.
233
+ */
234
+ export async function heldFolders(entries, { tsOf, fromMs = 0 }) {
235
+ const turns = new Map();
236
+ if (allowedFolders() === null) return turns;
237
+ for (const e of entries) {
238
+ for await (const r of e.files[0].rows()) {
239
+ if (!isSaid(r)) continue;
240
+ const t = tsOf(r);
241
+ if (t && t < fromMs) continue;
242
+ const dir = String(r.cwd ?? e.cwd ?? '');
243
+ if (!dir || ignoredBy(dir) || !heldBy(dir)) continue;
244
+ turns.set(dir, (turns.get(dir) ?? 0) + 1);
245
+ }
246
+ }
247
+ return turns;
208
248
  }
package/lib/stream.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  // that has a database yields rows straight from its query. Nothing is written to disk
8
8
  // on the way — so no unscrubbed copy of a session ever sits in a temp directory.
9
9
  import { cleanRow, queuedTurn } from './scrub.mjs';
10
- import { ignoredBy } from './ignore.mjs';
10
+ import { ignoredBy, isSaid, turnHeld } from './ignore.mjs';
11
11
  import { offTheRecord } from './state.mjs';
12
12
 
13
13
  const TAIL = 8;
@@ -19,6 +19,7 @@ const TAIL_CHARS = 300;
19
19
  export async function* streamCutScrub(rows, { tsOf, fromMs = 0, toMs = Infinity }) {
20
20
  const tail = [];
21
21
  let started = false, hidden = false;
22
+ let waiting = null; // the current turn waits for an allowance: set at each turn the person typed, inherited by its answer
22
23
  const off = offTheRecord(); // the stretches the person had recording paused
23
24
 
24
25
  for await (const row of rows) {
@@ -27,6 +28,13 @@ export async function* streamCutScrub(rows, { tsOf, fromMs = 0, toMs = Infinity
27
28
  // passed through an excluded folder loses those turns and keeps the rest.
28
29
  if (row?.cwd && ignoredBy(String(row.cwd))) continue;
29
30
  const t = tsOf(row);
31
+ // …and a TURN the person made in a folder they have not allowed (or before they allowed it) waits, with its answer:
32
+ // judged at the row the person typed, by the folder they spoke in, and inherited by every row up to the next one —
33
+ // so an agent that steps into /tmp mid-answer does not cut the answer, and an answer given while the person sat in
34
+ // another project does not go up with the allowed work around it (ignore.mjs `turnHeld`, 2026-10-12). Before the
35
+ // first typed turn a row is judged by its own folder.
36
+ if (row?.cwd && (isSaid(row) || waiting === null)) waiting = turnHeld(String(row.cwd), t); // a turn with no folder keeps the last verdict
37
+ if (waiting) continue;
30
38
  // …and a row from a paused stretch does not either: paused means off the record, not sent later. A row with no
31
39
  // time goes with the row before it, as it does at the window's edge.
32
40
  if (off) { if (t) hidden = off(t); if (hidden) continue; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diffin/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Capture your AI work history into diffin",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://diffin.work",
@@ -18,6 +18,6 @@
18
18
  "templates"
19
19
  ],
20
20
  "scripts": {
21
- "test": "for f in bin/*.mjs lib/*.mjs lib/sources/*.mjs; do node --check $f || exit 1; done && node test/no-shadowed-import.mjs && node test/ui.mjs && node test/scrub-self.mjs && node test/scrub-rules.mjs && node test/run-pin.mjs && node test/capture.mjs && node test/capture-all.mjs && node test/ignore.mjs && node test/allow.mjs && node test/machine.mjs && node test/launch.mjs && node test/command.mjs && node test/init.mjs && node test/onboard.mjs && node test/watch.mjs && node test/journey.mjs && node test/net-https.mjs && node test/net-429.mjs && node test/state.mjs && node test/hermes.mjs && node test/codex.mjs && node test/antigravity.mjs && node test/status.mjs"
21
+ "test": "for f in bin/*.mjs lib/*.mjs lib/sources/*.mjs; do node --check $f || exit 1; done && node test/no-shadowed-import.mjs && node test/ui.mjs && node test/scrub-self.mjs && node test/scrub-rules.mjs && node test/run-pin.mjs && node test/capture.mjs && node test/capture-all.mjs && node test/ignore.mjs && node test/allow.mjs && node test/turn-hold.mjs && node test/machine.mjs && node test/launch.mjs && node test/command.mjs && node test/init.mjs && node test/onboard.mjs && node test/watch.mjs && node test/journey.mjs && node test/net-https.mjs && node test/net-429.mjs && node test/state.mjs && node test/hermes.mjs && node test/codex.mjs && node test/antigravity.mjs && node test/status.mjs"
22
22
  }
23
23
  }
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: capture
3
- description: Records the work of every agent session on this machine to diffin (the AI-work ledger). Run `diffin capture` at the end of ANY turn in which a step of work got done — in any kind of work, not only code. A step is done when (1) something left the person's hands: sent, submitted, published, shared, handed over, exported, committed, merged, deployed; (2) a version was accepted: a draft, design, wording, plan or fix the person said yes to after revising; (3) a question got its answer: a research, comparison, calculation or diagnosis the person will act on; (4) a direction was decided; (5) the person wrapped up, moved to something else, or a long turn came to a stop. Examples outside code: an email ready to send, a report section finished, a slide deck shared, a spreadsheet analysis answered, a contract clause agreed, a lesson plan done, an application submitted. Always when the user says "기록해", "캡처해", "record this", "capture". The bar is low on purpose: run it after each finished step, not only at milestones, and when unsure, run it — the command itself ends a call that comes too soon, without sending, and says when the next may go up, so an extra call costs nothing and a missed one loses the record. Runs alone, with no confirmation.
3
+ description: "Records the work of every agent session on this machine to diffin (the AI-work ledger). Run `diffin capture` at the end of ANY turn in which a step of work got done, in any kind of work, not only code. A step is done when (1) something left the person's hands (sent, submitted, published, shared, exported, committed, merged, deployed); (2) a version was accepted (a draft, design, plan or fix the person said yes to); (3) a question got its answer (research, comparison, diagnosis the person will act on); (4) a direction was decided; (5) the person wrapped up or moved on. Outside code: an email ready to send, a report section finished, a deck shared, an analysis answered. Always when the user says \"기록해\", \"캡처해\", \"record this\", \"capture\". The bar is low: when unsure, run it. The command ends a call that comes too soon without sending, so an extra call costs nothing and a missed one loses the record. A failed call never ends capturing: run it again at the next step. Runs alone, no confirmation."
4
4
  license: Proprietary. Part of the diffin CLI.
5
5
  compatibility: Requires the diffin CLI on PATH (`npx @diffin/cli onboard` installs it; or npm i -g @diffin/cli), signed in once with `diffin login`, and network access to the diffin server. Reads the agent session transcripts on this machine — every session that moved since it was last recorded. Capture supports Claude Code, Codex, Antigravity and Hermes sessions; under other agents the command says so.
6
6
  allowed-tools: Bash(diffin capture:*)
7
7
  metadata:
8
8
  author: diffin
9
- version: "3.13"
9
+ version: "3.14"
10
10
  ---
11
11
 
12
12
  # Capture (diffin)
@@ -96,6 +96,11 @@ Then one short line at most, and stop:
96
96
  | a daily limit | nothing — tomorrow |
97
97
  | anything else | show the message once; do not retry |
98
98
 
99
+ "Do not retry" is about THAT call, never about the session. A capture that failed, crashed or was refused does not
100
+ end capturing here: at the next finished step, run `diffin capture` again as if nothing had happened — the cause may
101
+ already be fixed, and the command decides for itself whether it may send. Do not leave capturing to another session,
102
+ another agent or the person because one call failed.
103
+
99
104
  ## Never
100
105
 
101
106
  - Write, edit or summarise the record yourself — the server extracts it.