kankaku 0.5.0 → 0.5.1

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
@@ -804,7 +804,9 @@ On `session_start`, for the orchestrator role with a UI available:
804
804
  sorted by name, plus "— skip —"), then for the project (active projects
805
805
  of that client, plus "(no project)" and "— skip —"). Declining at either
806
806
  step — "— skip —" or dismissing the dialog — cancels the whole pick and
807
- is remembered for the session.
807
+ is remembered for the session. The picker shows the freshly refreshed
808
+ catalog when the hub answered within the deadline described in "Caching
809
+ and offline behaviour" below; otherwise it falls back to the cache.
808
810
 
809
811
  After a pick, kankaku asks whether to remember it for this repository; a
810
812
  "yes" merges `clientId`/`projectId` into `<KANKAKU_DIR>/config.json`.
@@ -826,19 +828,36 @@ a project) in place of the legacy client label, both idle and during a run.
826
828
  ### Caching and offline behaviour
827
829
 
828
830
  The catalog (clients/projects) is cached machine-wide at
829
- `~/.kankaku/catalog.json` with a 6-hour TTL. On startup: a fresh cache is
830
- used as-is; a stale cache is used immediately while a refresh happens in
831
- the background; when there is no cache at all, one refresh is awaited
832
- (bounded by the hub client's own request timeout, 3s by default) before
833
- falling back. If the hub is unreachable and there is no cache, kankaku
834
- notifies once (`kankaku: hub unreachable, using local labels`) and
835
- continues exactly as it would without a hub configured. `/kankaku catalog
836
- refresh` forces a refresh on demand. The cache file is always written
837
- owner-only (`0600`); if kankaku is the first thing to ever create
838
- `~/.kankaku` itself (no project has put its own `.kankaku` there), the
839
- directory is created owner-only (`0700`) too — but an already-existing
840
- `~/.kankaku` is never chmod'd, since it may be a project's own kankaku
841
- directory (see "The registry" below for the same rule applied to `run/`).
831
+ `~/.kankaku/catalog.json`. On `session_start`, for the orchestrator role
832
+ with a UI available, kankaku always starts a background refresh when a
833
+ cache already exists — regardless of the cache's age — so a client or
834
+ project created in the hub minutes ago shows up without waiting for a TTL
835
+ to expire (the 6-hour TTL and `isStale()` still exist and still gate other
836
+ callers, but session start no longer depends on them). If the target
837
+ resolves silently from the project config file or `repo_paths` against
838
+ the cached snapshot, `ensurePicked` returns immediately without waiting
839
+ for that refresh at all; it keeps running in the background and
840
+ `catalog.read()` reflects it once it lands, exactly as before. Only when
841
+ the picker is actually about to be shown does kankaku wait for the
842
+ in-flight refresh, bounded by a short deadline (1.5s by default,
843
+ `pickerRefreshDeadlineMs`): if the hub answers in time, the picker offers
844
+ the fresh clients/projects; otherwise (or if the refresh fails) it falls
845
+ back to the cached snapshot silently, and the refresh keeps running
846
+ in the background rather than being aborted. `/kankaku target pick` (the
847
+ explicit re-pick command) follows the same wait-then-fall-back rule. When
848
+ there is no cache at all, one refresh is still awaited (bounded by the hub
849
+ client's own request timeout, 3s by default) before falling back — this
850
+ path is unchanged. If the hub is unreachable and there is no cache,
851
+ kankaku notifies once (`kankaku: hub unreachable, using local labels`) and
852
+ continues exactly as it would without a hub configured; a background
853
+ refresh that merely fails once a cache already exists is silent, with no
854
+ notification. `/kankaku catalog refresh` still forces a refresh on demand
855
+ independently of any of this. The cache file is always written owner-only
856
+ (`0600`); if kankaku is the first thing to ever create `~/.kankaku` itself
857
+ (no project has put its own `.kankaku` there), the directory is created
858
+ owner-only (`0700`) too — but an already-existing `~/.kankaku` is never
859
+ chmod'd, since it may be a project's own kankaku directory (see "The
860
+ registry" below for the same rule applied to `run/`).
842
861
 
843
862
  ### Privacy (catalog)
844
863
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "pi extension that records agent work time per prompt, excluding waits for the user, with subagent linkage and task/session views",
5
5
  "license": "MIT",
6
6
  "author": "soyunninja",
@@ -32,12 +32,21 @@ export interface SessionTargetDeps {
32
32
  * per-request. Defaults to 5000.
33
33
  */
34
34
  firstFetchDeadlineMs?: number;
35
+ /**
36
+ * Deadline, in ms, for awaiting an already-in-flight background refresh
37
+ * before showing the picker with the cached snapshot instead — see
38
+ * {@link getSnapshotForPicker}. The refresh itself is never aborted at
39
+ * this deadline; it keeps running, and a later `catalog.read()` call
40
+ * (e.g. the next run) sees its result once it lands. Defaults to 1500.
41
+ */
42
+ pickerRefreshDeadlineMs?: number;
35
43
  /** Injectable for tests; defaults to the global timer functions. */
36
44
  setTimeout?: (handler: () => void, ms: number) => NodeJS.Timeout;
37
45
  clearTimeout?: (timer: NodeJS.Timeout) => void;
38
46
  }
39
47
 
40
48
  const DEFAULT_FIRST_FETCH_DEADLINE_MS = 5000;
49
+ const DEFAULT_PICKER_REFRESH_DEADLINE_MS = 1500;
41
50
 
42
51
  export interface SessionTarget {
43
52
  /** Restore the session-level target (or its remembered "skipped" state) from the last `kankaku-target` entry. */
@@ -86,6 +95,7 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
86
95
  const scheduleTimeout = deps.setTimeout ?? setTimeout;
87
96
  const cancelTimeout = deps.clearTimeout ?? clearTimeout;
88
97
  const firstFetchDeadlineMs = deps.firstFetchDeadlineMs ?? DEFAULT_FIRST_FETCH_DEADLINE_MS;
98
+ const pickerRefreshDeadlineMs = deps.pickerRefreshDeadlineMs ?? DEFAULT_PICKER_REFRESH_DEADLINE_MS;
89
99
 
90
100
  /** Session-level override, restored on `session_start` or set by an explicit pick/skip/legacy command. */
91
101
  let sessionOverride: WorkTargetSessionOverride;
@@ -166,30 +176,18 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
166
176
  }
167
177
 
168
178
  /**
169
- * Resolve a catalog snapshot to show the picker with: the cached
170
- * snapshot immediately when fresh; the cached snapshot immediately with
171
- * a fire-and-forget refresh when stale (not deadline-bound: it is never
172
- * awaited, so a slow or hung refresh here cannot block anything); or,
173
- * when there is no cache at all, one awaited refresh bounded by an
174
- * *overall* deadline ({@link SessionTargetDeps.firstFetchDeadlineMs},
175
- * default 5000ms) — not merely the hub client's own per-request timeout,
176
- * which alone does not bound the whole sequence of a lazy auth,
177
- * pagination, and a possible 401 retry. The deadline is enforced with an
179
+ * The no-cache-at-all path: one awaited refresh bounded by an *overall*
180
+ * deadline ({@link SessionTargetDeps.firstFetchDeadlineMs}, default
181
+ * 5000ms) — not merely the hub client's own per-request timeout, which
182
+ * alone does not bound the whole sequence of a lazy auth, pagination,
183
+ * and a possible 401 retry. The deadline is enforced with an
178
184
  * `AbortSignal` composed, per request, with that request's own
179
185
  * per-request timeout (see `pocketbase-client.ts#rawFetch`). Notifies
180
186
  * "hub unreachable" at most once when no snapshot is available at all,
181
187
  * whether because the hub failed outright or because the deadline fired
182
188
  * first — both are treated identically.
183
189
  */
184
- async function getSnapshot(ctx: ExtensionContext): Promise<CatalogSnapshot | undefined> {
185
- const cached = deps.catalog.read();
186
- if (cached) {
187
- if (deps.catalog.isStale()) {
188
- void deps.catalog.refresh();
189
- }
190
- return cached;
191
- }
192
-
190
+ async function awaitFirstFetch(ctx: ExtensionContext): Promise<CatalogSnapshot | undefined> {
193
191
  const controller = new AbortController();
194
192
  const timer = scheduleTimeout(() => controller.abort(), firstFetchDeadlineMs);
195
193
  let fresh: CatalogSnapshot | undefined;
@@ -204,6 +202,50 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
204
202
  return fresh;
205
203
  }
206
204
 
205
+ /**
206
+ * Races an already-started background refresh against
207
+ * {@link SessionTargetDeps.pickerRefreshDeadlineMs} (default 1500ms): if
208
+ * the refresh lands in time (and did not fail — `refresh()` resolving
209
+ * `undefined` falls back exactly like a deadline miss, with no
210
+ * notification), the picker shows the fresh snapshot; otherwise it
211
+ * shows `cached`. The refresh is never aborted here — it keeps running
212
+ * in the background, and `catalog.read()` reflects it once it resolves,
213
+ * exactly as it would without a picker in the way.
214
+ */
215
+ function awaitPickerRefresh(
216
+ refreshPromise: Promise<CatalogSnapshot | undefined>,
217
+ cached: CatalogSnapshot,
218
+ ): Promise<CatalogSnapshot> {
219
+ return new Promise((resolve) => {
220
+ let settled = false;
221
+ const timer = scheduleTimeout(() => {
222
+ if (settled) return;
223
+ settled = true;
224
+ resolve(cached);
225
+ }, pickerRefreshDeadlineMs);
226
+ void refreshPromise.then((fresh) => {
227
+ if (settled) return;
228
+ settled = true;
229
+ cancelTimeout(timer);
230
+ resolve(fresh ?? cached);
231
+ });
232
+ });
233
+ }
234
+
235
+ /** Resolves silently (project config / `repoPaths`) against `snapshot`, or shows the picker with it. */
236
+ async function resolveOrShowPicker(pi: ExtensionAPI, ctx: ExtensionContext, snapshot: CatalogSnapshot): Promise<void> {
237
+ const projectIds = deps.resolveProjectConfigIds();
238
+ const resolved = resolveWorkTarget({
239
+ project: projectIds,
240
+ cwd: cwd(),
241
+ clients: snapshot.clients,
242
+ projects: snapshot.projects,
243
+ });
244
+ if (resolved) return;
245
+
246
+ await runPicker(pi, ctx, snapshot);
247
+ }
248
+
207
249
  async function runPicker(pi: ExtensionAPI, ctx: ExtensionContext, snapshot: CatalogSnapshot): Promise<void> {
208
250
  const result = await pickTarget(ctx, snapshot);
209
251
 
@@ -227,24 +269,44 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
227
269
  if (deps.role !== "orchestrator" || !ctx.hasUI) return;
228
270
  if (sessionOverride !== undefined) return;
229
271
 
230
- const snapshot = await getSnapshot(ctx);
231
- if (!snapshot) return;
272
+ const cached = deps.catalog.read();
273
+ if (!cached) {
274
+ const fresh = await awaitFirstFetch(ctx);
275
+ if (!fresh) return;
276
+ await resolveOrShowPicker(pi, ctx, fresh);
277
+ return;
278
+ }
279
+
280
+ // Always start a refresh, regardless of staleness (the owner hit a
281
+ // clients/projects change that a merely-stale-TTL check missed). Silent
282
+ // resolution below returns without ever awaiting it; the refresh keeps
283
+ // running and `catalog.read()` reflects it once it lands.
284
+ const refreshPromise = deps.catalog.refresh();
232
285
 
233
286
  const projectIds = deps.resolveProjectConfigIds();
234
287
  const resolved = resolveWorkTarget({
235
288
  project: projectIds,
236
289
  cwd: cwd(),
237
- clients: snapshot.clients,
238
- projects: snapshot.projects,
290
+ clients: cached.clients,
291
+ projects: cached.projects,
239
292
  });
240
293
  if (resolved) return;
241
294
 
295
+ const snapshot = await awaitPickerRefresh(refreshPromise, cached);
242
296
  await runPicker(pi, ctx, snapshot);
243
297
  }
244
298
 
245
299
  async function pick(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
246
- const snapshot = await getSnapshot(ctx);
247
- if (!snapshot) return;
300
+ const cached = deps.catalog.read();
301
+ if (!cached) {
302
+ const fresh = await awaitFirstFetch(ctx);
303
+ if (!fresh) return;
304
+ await runPicker(pi, ctx, fresh);
305
+ return;
306
+ }
307
+
308
+ const refreshPromise = deps.catalog.refresh();
309
+ const snapshot = await awaitPickerRefresh(refreshPromise, cached);
248
310
  await runPicker(pi, ctx, snapshot);
249
311
  }
250
312