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 +4 -2
- package/README.md +16 -5
- package/package.json +1 -1
- package/src/auto-sync.ts +32 -0
- package/src/handle-hook.ts +29 -5
- package/src/sync-cli.ts +43 -20
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).
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
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
package/src/auto-sync.ts
ADDED
|
@@ -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
|
+
}
|
package/src/handle-hook.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
234
|
-
|
|
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
|
|
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
|
+
}
|