kankaku-claude 0.1.0 → 0.2.0

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/CHANGELOG.md CHANGED
@@ -28,8 +28,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
28
28
  `/kankaku:status`, `/kankaku:setup` slash commands.
29
29
 
30
30
  - Manual hub sync via `node src/cli.ts sync [all|status]` and the
31
- `/kankaku:sync` slash command (default sync only). No automatic sync hooks;
32
- prompts are omitted by default.
31
+ `/kankaku:sync` slash command (default sync only). Prompts are omitted by default.
32
+ - Best-effort automatic hub sync on `SessionStart`, `Stop`, and `SessionEnd`;
33
+ disabled with `KANKAKU_SYNC_AUTO=0`. Missing credentials and sync failures
34
+ never block local records or cleanup.
33
35
 
34
36
  ### Fixed
35
37
 
package/README.md CHANGED
@@ -20,7 +20,7 @@ Records are written in kankaku's own `WorkRecord` schema
20
20
  (`WORK_RECORD_SCHEMA = 1`), the exact one the kankaku pi extension writes to
21
21
  its own `worklog.jsonl`. This means kankaku's existing report and export
22
22
  tooling can read this plugin's worklog unchanged. kankaku-claude also exposes
23
- manual hub sync through kankaku's public hub adapters (below).
23
+ manual and best-effort automatic hub sync through kankaku's public hub adapters (below).
24
24
 
25
25
  ## Requirements
26
26
 
@@ -114,8 +114,19 @@ Optional environment settings:
114
114
  | `KANKAKU_SYNC_PROMPT` | Prompt privacy: omitted by default; set `truncated` or `full` to include prompts. |
115
115
  | `KANKAKU_SYNC_WINDOW_HOURS` | Recent sync window in hours (defaults to 24). |
116
116
  | `KANKAKU_SYNC_RECORDS` | Set to `0` to disable uploading raw `work_records` children; consolidated `task_entries` still sync. |
117
+ | `KANKAKU_SYNC_AUTO` | Set to `0` to disable automatic sync; manual sync remains available. |
118
+ | `KANKAKU_SYNC_MIN_INTERVAL_MINUTES` | Minimum interval between automatic per-prompt attempts (defaults to 5; `0` disables throttling). |
117
119
 
118
- This first slice is **manual-only**: no automatic sync hooks run yet.
120
+ ### Automatic hub sync
121
+
122
+ With valid hub credentials, heavy hooks attempt a best-effort sync at session start
123
+ (after recovery), after each settled prompt has been appended, and on session end
124
+ (after any interrupted record is appended, before cleanup). Missing/invalid
125
+ credentials silently skip automatic sync. Failures are reported on stderr but
126
+ never prevent local worklog writes or session cleanup. Prompt privacy, machine,
127
+ window, and record settings above apply to both manual and automatic sync.
128
+ Automatic runs use kankaku's change detection and per-prompt throttle; session
129
+ boundaries are not throttled. Set `KANKAKU_SYNC_AUTO=0` to opt out.
119
130
 
120
131
  ## Where the files live
121
132
 
@@ -170,9 +181,9 @@ also runs for the current session's own leftover state at `SessionEnd`.
170
181
  document a link between a `SubagentStart`/`SubagentStop` pair and the
171
182
  `tool_use_id` that launched it, so kankaku-claude cannot join them; the
172
183
  subagent's own time is not separately measured here.
173
- - **Hub sync is manual-only.** Use `/kankaku:sync` or the direct CLI as
174
- described above; no automatic sync hooks run yet. Prompts are omitted
175
- from uploads unless `KANKAKU_SYNC_PROMPT` is `truncated` or `full`.
184
+ - **Hub sync is best-effort.** Use `/kankaku:sync` or the direct CLI to retry
185
+ failures or request a full sync. Prompts are omitted from uploads unless
186
+ `KANKAKU_SYNC_PROMPT` is `truncated` or `full`.
176
187
 
177
188
  See [kankaku.io](https://kankaku.io) for the pi extension this plugin shares
178
189
  its worklog format with.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku-claude",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Claude Code plugin that records how long the agent works on each user prompt, in kankaku's worklog format.",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,32 @@
1
+ import type { SyncTrigger } from "kankaku/hub";
2
+ import type { SyncCliDeps } from "./sync-cli.ts";
3
+
4
+ export interface AutoSyncDeps extends SyncCliDeps {
5
+ stderr: (message: string) => void;
6
+ }
7
+
8
+ /** Best-effort heavy-hook sync; credential/transport errors never escape into record handling. */
9
+ export async function autoSync(trigger: SyncTrigger, deps: AutoSyncDeps): Promise<void> {
10
+ if (deps.env.KANKAKU_SYNC_AUTO === "0") return;
11
+ try {
12
+ const { resolveHubCredentials } = await import("kankaku/hub");
13
+ const { homedir } = await import("node:os");
14
+ const hub = resolveHubCredentials({ env: deps.env, homeDir: deps.homeDir ?? (() => deps.env.HOME || homedir()) });
15
+ if (!hub.credentials || hub.invalidReason) return;
16
+ const { syncConfigured } = await import("./sync-cli.ts");
17
+ // One deadline across catalog and upload requests; the public client also
18
+ // imposes its own per-request timeout. No timer persists after the hook.
19
+ const deadline = AbortSignal.timeout(8_000);
20
+ const doFetch = deps.fetch ?? fetch;
21
+ const boundedFetch: typeof fetch = (input, init) => doFetch(input, {
22
+ ...init,
23
+ signal: init?.signal ? AbortSignal.any([init.signal, deadline]) : deadline,
24
+ });
25
+ const result = await syncConfigured({ ...deps, fetch: boundedFetch }, hub.credentials, { trigger });
26
+ if (result.error || result.failed.length) {
27
+ deps.stderr(`kankaku auto-sync: ${result.error ?? result.failed.map((f) => f.reason).join("; ")}`);
28
+ }
29
+ } catch (error) {
30
+ deps.stderr(`kankaku auto-sync: ${error instanceof Error ? error.message : String(error)}`);
31
+ }
32
+ }
@@ -7,6 +7,7 @@ import { splitPrompts, type PromptEvents } from "./prompts.ts";
7
7
  import { readCost, deleteCost, sweepStaleCostFiles } from "./cost-store.ts";
8
8
  import type { Event } from "./events.ts";
9
9
  import type { WorkLog } from "kankaku/ports";
10
+ import type { SyncTrigger } from "kankaku/hub";
10
11
 
11
12
  export interface HandleHookDeps {
12
13
  env: NodeJS.ProcessEnv;
@@ -18,6 +19,8 @@ export interface HandleHookDeps {
18
19
  isAlive: (pid: number) => boolean;
19
20
  runPs: (pid: number) => PsInfo | undefined;
20
21
  stderr: (message: string) => void;
22
+ /** Optional heavy-hook seam for tests. */
23
+ autoSync?: (trigger: SyncTrigger) => Promise<void>;
21
24
  }
22
25
 
23
26
  const STOP_COST_WAIT_POLL_MS = 100;
@@ -118,7 +121,7 @@ export async function handleHook(input: unknown, deps: HandleHookDeps): Promise<
118
121
  case "SessionEnd": {
119
122
  const reason = readString(raw.reason) ?? "other";
120
123
  appendEvent(paths.eventsFile, { ts, event: "SessionEnd", reason });
121
- await handleSessionEnd(paths, sessionId, ts, deps);
124
+ await handleSessionEnd(paths, sessionId, cwd, ts, deps);
122
125
  return;
123
126
  }
124
127
  default:
@@ -166,6 +169,7 @@ async function handleStop(paths: ResolvedPaths, sessionId: string, deps: HandleH
166
169
  writeState(paths.stateFile, { ...state, promptOpen: null, permissionOpen: null });
167
170
  const keep = events.slice(0, events.length - last.events.length);
168
171
  dropSettledPrompts(paths.eventsFile, keep);
172
+ if (core) await syncHeavy("agent_settled", state.cwd, deps);
169
173
  }
170
174
 
171
175
  async function handleSessionStart(
@@ -194,7 +198,10 @@ async function handleSessionStart(
194
198
  }
195
199
 
196
200
  const existing = readState(paths.stateFile);
197
- if (existing) return; // resume/fork on an existing state: keep it as-is
201
+ if (existing) {
202
+ await syncHeavy("session_start", cwd, deps);
203
+ return; // resume/fork on an existing state: keep it as-is
204
+ }
198
205
 
199
206
  const pid = resolveClaudePid({ startPid: process.ppid, runPs: deps.runPs });
200
207
  const resolvedInfo = deps.runPs(pid);
@@ -209,9 +216,10 @@ async function handleSessionStart(
209
216
  permissionOpen: null,
210
217
  };
211
218
  writeState(paths.stateFile, state);
219
+ await syncHeavy("session_start", cwd, deps);
212
220
  }
213
221
 
214
- async function handleSessionEnd(paths: ResolvedPaths, sessionId: string, ts: number, deps: HandleHookDeps): Promise<void> {
222
+ async function handleSessionEnd(paths: ResolvedPaths, sessionId: string, cwd: string, ts: number, deps: HandleHookDeps): Promise<void> {
215
223
  const state = readState(paths.stateFile);
216
224
  if (state?.promptOpen) {
217
225
  const { replayPrompt } = await import("./replay.ts");
@@ -230,8 +238,24 @@ async function handleSessionEnd(paths: ResolvedPaths, sessionId: string, ts: num
230
238
  }
231
239
  }
232
240
  }
233
- deleteSessionFiles(paths);
234
- deleteCost(deps.env, sessionId);
241
+ try {
242
+ await syncHeavy("session_shutdown", state?.cwd ?? cwd, deps);
243
+ } finally {
244
+ deleteSessionFiles(paths);
245
+ deleteCost(deps.env, sessionId);
246
+ }
247
+ }
248
+
249
+ async function syncHeavy(trigger: SyncTrigger, cwd: string, deps: HandleHookDeps): Promise<void> {
250
+ try {
251
+ if (deps.autoSync) await deps.autoSync(trigger);
252
+ else {
253
+ const { autoSync } = await import("./auto-sync.ts");
254
+ await autoSync(trigger, { env: deps.env, cwd, now: deps.now, stderr: deps.stderr });
255
+ }
256
+ } catch (error) {
257
+ deps.stderr(`kankaku auto-sync: ${error instanceof Error ? error.message : String(error)}`);
258
+ }
235
259
  }
236
260
 
237
261
  function deleteSessionFiles(paths: ResolvedPaths): void {
package/src/sync-cli.ts CHANGED
@@ -7,8 +7,10 @@ import {
7
7
  computeSyncStatus, createPocketBaseCatalogFetcher, resolveHubCredentials,
8
8
  runSync, safeHomeDir,
9
9
  } from "kankaku/hub";
10
+ import type { SyncTrigger } from "kankaku/hub";
10
11
  import { resolveKankakuDir } from "./paths.ts";
11
12
  import type { CliResult } from "./cli-core.ts";
13
+ import type { WorkSink } from "kankaku/ports";
12
14
 
13
15
  export interface SyncCliDeps {
14
16
  env: NodeJS.ProcessEnv;
@@ -66,26 +68,7 @@ export async function runSyncCli(args: string[], deps: SyncCliDeps): Promise<Cli
66
68
  if (hub.invalidReason) return { stdout: "", stderr: `kankaku sync: invalid hub URL: ${hub.invalidReason}\n`, exitCode: 1 };
67
69
  if (!hub.credentials) return { stdout: "", stderr: "kankaku sync: hub credentials are not configured (KANKAKU_PB_URL, KANKAKU_PB_EMAIL, KANKAKU_PB_PASSWORD).\n", exitCode: 1 };
68
70
 
69
- const credentials = hub.credentials;
70
- const client = new PocketBaseClient({ ...credentials, ...(deps.fetch ? { fetch: deps.fetch } : {}) });
71
- const catalog = new CachedCatalog({
72
- filePath: join(safeHomeDir(homeDir) ?? tmpdir(), ".kankaku", "catalog.json"),
73
- url: credentials.url,
74
- clock: { now: deps.now },
75
- fetchCatalog: createPocketBaseCatalogFetcher(client),
76
- });
77
- // Refresh before resolving assignments; on an offline hub the cached snapshot remains usable.
78
- await catalog.refresh();
79
- const snapshot = catalog.read();
80
- const sink = new PocketBaseSink({
81
- client, clients: snapshot?.clients ?? [], projects: snapshot?.projects ?? [],
82
- machine: deps.env.KANKAKU_MACHINE || (deps.hostname ?? hostname)(),
83
- promptMode: promptMode(deps.env), syncRecords: deps.env.KANKAKU_SYNC_RECORDS !== "0",
84
- agent: "claude-code", plugin: "kankaku-claude", pluginVersion: packageVersion(),
85
- });
86
- const summary = await runSync({
87
- log, sink, stateStore, clock: { now: deps.now }, target: credentials.url, windowHours: hours,
88
- }, { full: args[0] === "all" });
71
+ const summary = await syncConfigured(deps, hub.credentials, { full: args[0] === "all" });
89
72
  const stdout = `uploaded: ${summary.uploaded}, updated: ${summary.updated}, skipped: ${summary.skipped}, failed: ${summary.failed.length}\n`;
90
73
  if (summary.locked) return { stdout, stderr: "kankaku sync: another sync is running.\n", exitCode: 1 };
91
74
  if (summary.error || summary.failed.length) {
@@ -97,3 +80,43 @@ export async function runSyncCli(args: string[], deps: SyncCliDeps): Promise<Cli
97
80
  return { stdout: "", stderr: `kankaku sync: ${error instanceof Error ? error.message : String(error)}\n`, exitCode: 1 };
98
81
  }
99
82
  }
83
+
84
+ /** Shared manual/automatic hub adapters and Claude attribution. */
85
+ export async function syncConfigured(
86
+ deps: SyncCliDeps,
87
+ credentials: NonNullable<ReturnType<typeof resolveHubCredentials>["credentials"]>,
88
+ options: { full?: boolean; trigger?: SyncTrigger } = {},
89
+ ) {
90
+ const homeDir = deps.homeDir ?? (() => deps.env.HOME || homedir());
91
+ const dir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
92
+ const log = new JsonlWorkLog(dir);
93
+ const stateStore = new SyncStateStore({ dir, pid: process.pid, now: deps.now });
94
+ // Build the catalog only after runSync's unchanged-log and throttle gates.
95
+ // The public runner still owns locking, planning and the sync watermark.
96
+ const sink: WorkSink = { push: async (tasks) => {
97
+ if (tasks.length === 0) return [];
98
+ const client = new PocketBaseClient({ ...credentials, ...(deps.fetch ? { fetch: deps.fetch } : {}) });
99
+ const catalog = new CachedCatalog({
100
+ filePath: join(safeHomeDir(homeDir) ?? tmpdir(), ".kankaku", "catalog.json"),
101
+ url: credentials.url,
102
+ clock: { now: deps.now },
103
+ fetchCatalog: createPocketBaseCatalogFetcher(client),
104
+ });
105
+ // On an offline hub the cached snapshot remains usable.
106
+ await catalog.refresh();
107
+ const snapshot = catalog.read();
108
+ return new PocketBaseSink({
109
+ client, clients: snapshot?.clients ?? [], projects: snapshot?.projects ?? [],
110
+ machine: deps.env.KANKAKU_MACHINE || (deps.hostname ?? hostname)(),
111
+ promptMode: promptMode(deps.env), syncRecords: deps.env.KANKAKU_SYNC_RECORDS !== "0",
112
+ agent: "claude-code", plugin: "kankaku-claude", pluginVersion: packageVersion(),
113
+ }).push(tasks);
114
+ } };
115
+ const interval = Number(deps.env.KANKAKU_SYNC_MIN_INTERVAL_MINUTES);
116
+ const minAutoIntervalMs = deps.env.KANKAKU_SYNC_MIN_INTERVAL_MINUTES !== undefined && Number.isFinite(interval) && interval >= 0
117
+ ? interval * 60_000 : undefined;
118
+ return runSync({
119
+ log, sink, stateStore, clock: { now: deps.now }, target: credentials.url,
120
+ windowHours: windowHours(deps.env), ...(minAutoIntervalMs !== undefined ? { minAutoIntervalMs } : {}),
121
+ }, options);
122
+ }