git-coco 0.54.0 → 0.54.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.
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
61
61
  /**
62
62
  * Current build version from package.json
63
63
  */
64
- const BUILD_VERSION = "0.54.0";
64
+ const BUILD_VERSION = "0.54.1";
65
65
 
66
66
  const isInteractive = (config) => {
67
67
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -20220,9 +20220,16 @@ function applyCommitComposeAction(state, action) {
20220
20220
  field: state.field === 'summary' ? 'body' : 'summary',
20221
20221
  };
20222
20222
  case 'setEditing':
20223
+ // Audit finding #12: defensively clear `streamingPreview` when
20224
+ // editing toggles off AND no draft is in flight. The current
20225
+ // input pipeline never triggers this combination, but the
20226
+ // reducer is the source of truth — if a future code path
20227
+ // toggles editing off mid-stream, the preview shouldn't linger
20228
+ // below an idle compose panel.
20223
20229
  return {
20224
20230
  ...state,
20225
20231
  editing: action.value,
20232
+ streamingPreview: !action.value && !state.loading ? undefined : state.streamingPreview,
20226
20233
  };
20227
20234
  case 'setLoading':
20228
20235
  // Clearing loading also clears any in-flight streaming preview;
@@ -20234,6 +20241,22 @@ function applyCommitComposeAction(state, action) {
20234
20241
  streamingPreview: action.value ? state.streamingPreview : undefined,
20235
20242
  };
20236
20243
  case 'setDraft':
20244
+ // Audit finding #7: if the user has typed content in summary or
20245
+ // body, the AI draft would silently clobber their work with no
20246
+ // undo. Route the result to `pendingAiDraft` instead and surface
20247
+ // a confirmation message; the user accepts with `R` (replace)
20248
+ // or dismisses with Esc. Empty fields = safe to replace as
20249
+ // before, since there's nothing to lose.
20250
+ if (state.summary.trim() || state.body.trim()) {
20251
+ return {
20252
+ ...state,
20253
+ loading: false,
20254
+ streamingPreview: undefined,
20255
+ pendingAiDraft: action.value,
20256
+ message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
20257
+ details: undefined,
20258
+ };
20259
+ }
20237
20260
  // No `message` here — the loader → filled fields are the confirmation
20238
20261
  // that the AI generated something. A lingering "AI draft ready for
20239
20262
  // editing" line in the panel reads as stale state. The runtime still
@@ -20248,6 +20271,7 @@ function applyCommitComposeAction(state, action) {
20248
20271
  message: undefined,
20249
20272
  details: undefined,
20250
20273
  streamingPreview: undefined,
20274
+ pendingAiDraft: undefined,
20251
20275
  };
20252
20276
  case 'setResult':
20253
20277
  return {
@@ -20267,6 +20291,46 @@ function applyCommitComposeAction(state, action) {
20267
20291
  ...state,
20268
20292
  streamingPreview: action.value,
20269
20293
  };
20294
+ case 'setPendingAiDraft':
20295
+ // Audit finding #7: route the AI draft here (instead of straight
20296
+ // to summary/body via `setDraft`) when the user has unsaved
20297
+ // typing the draft would clobber. The dispatcher does the
20298
+ // user-content check; this reducer just stashes the draft and
20299
+ // surfaces a message inviting the user to accept or dismiss.
20300
+ return {
20301
+ ...state,
20302
+ loading: false,
20303
+ streamingPreview: undefined,
20304
+ pendingAiDraft: action.value,
20305
+ message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
20306
+ details: undefined,
20307
+ };
20308
+ case 'acceptPendingAiDraft':
20309
+ // Swap the pending draft into the editable fields and clear it.
20310
+ // Mirrors `setDraft`'s field positioning (focus on summary,
20311
+ // editing on) so the user lands in the same place whether they
20312
+ // accepted immediately or after deliberation.
20313
+ if (!state.pendingAiDraft)
20314
+ return state;
20315
+ return {
20316
+ ...state,
20317
+ ...splitCommitDraft(state.pendingAiDraft),
20318
+ field: 'summary',
20319
+ editing: true,
20320
+ loading: false,
20321
+ message: undefined,
20322
+ details: undefined,
20323
+ streamingPreview: undefined,
20324
+ pendingAiDraft: undefined,
20325
+ };
20326
+ case 'dismissPendingAiDraft':
20327
+ // User chose to keep their typing; drop the AI draft.
20328
+ return {
20329
+ ...state,
20330
+ pendingAiDraft: undefined,
20331
+ message: undefined,
20332
+ details: undefined,
20333
+ };
20270
20334
  case 'reset':
20271
20335
  // Drop message/details too — the post-commit "Created commit ..."
20272
20336
  // notification is already on the runtime status line (footer); a
@@ -20454,6 +20518,14 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
20454
20518
  // classify below.
20455
20519
  const stream = await chain.stream(variables, signal ? { signal } : undefined);
20456
20520
  let chunkCount = 0;
20521
+ let callbackFailureCount = 0;
20522
+ // Audit finding #13: cap consecutive callback failures so a
20523
+ // genuinely broken render handler can't tie up the LLM call
20524
+ // silently for the user's entire wait. Five strikes (out of an
20525
+ // expected ~50-500 chunks for a normal commit message) is enough
20526
+ // to ride out a transient blip but small enough to bail before
20527
+ // the user finishes waiting on a useless stream.
20528
+ const MAX_CALLBACK_FAILURES = 5;
20457
20529
  for await (const messageChunk of stream) {
20458
20530
  const text = coerceChunkText(messageChunk);
20459
20531
  if (!text)
@@ -20462,12 +20534,20 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
20462
20534
  chunkCount += 1;
20463
20535
  try {
20464
20536
  onChunk({ text, accumulated });
20537
+ // Successful callback resets the consecutive-failure counter —
20538
+ // we only bail on a STREAK of failures, not on isolated ones.
20539
+ callbackFailureCount = 0;
20465
20540
  }
20466
20541
  catch (callbackError) {
20467
20542
  // Deliberately swallow callback errors so a bad render handler
20468
20543
  // can't tank the entire LLM call. Log at verbose so users with
20469
20544
  // verbose mode on can still see what happened.
20470
- logger?.verbose(`executeChainStreaming: onChunk handler threw: ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
20545
+ callbackFailureCount += 1;
20546
+ logger?.verbose(`executeChainStreaming: onChunk handler threw (${callbackFailureCount}/${MAX_CALLBACK_FAILURES}): ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
20547
+ if (callbackFailureCount >= MAX_CALLBACK_FAILURES) {
20548
+ logger?.verbose(`executeChainStreaming: bailing stream — ${MAX_CALLBACK_FAILURES} consecutive callback failures suggest a broken render handler.`, { color: 'red' });
20549
+ throw new LangChainExecutionError(`executeChainStreaming: render handler failed ${MAX_CALLBACK_FAILURES} times in a row; aborting stream so the failure surfaces to the caller.`, { accumulatedLength: accumulated.length, chunkCount });
20550
+ }
20471
20551
  }
20472
20552
  }
20473
20553
  if (!accumulated) {
@@ -20501,15 +20581,22 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
20501
20581
  }
20502
20582
  catch (error) {
20503
20583
  // Cancellation classifier (#881 phase 3). Three signals: an
20504
- // explicitly aborted user signal (post-throw check), the
20505
- // standard DOM `AbortError`, or a Node `AbortSignal` with
20506
- // `signal.aborted === true` while a chain-internal error
20507
- // propagates. Any of these means "user wanted out," not "the
20508
- // call failed." Wrap the raw error so callers can pattern-match
20509
- // on `LangChainCancelledError` and carry the partial accumulated
20510
- // text in case the caller wants to salvage anything.
20584
+ // explicitly aborted user signal (post-throw check) or a thrown
20585
+ // `AbortError` from the standard DOM API. Either means "user
20586
+ // wanted out," not "the call failed." Wrap the raw error so
20587
+ // callers can pattern-match on `LangChainCancelledError` and
20588
+ // carry the partial accumulated text in case the caller wants
20589
+ // to salvage anything.
20590
+ //
20591
+ // Audit finding #8: an earlier implementation also fell back to
20592
+ // `error.message.includes('aborted')` as a third signal. That
20593
+ // substring heuristic is footgun-shaped — legitimate provider
20594
+ // errors ("model not aborted properly", future API copy) would
20595
+ // misclassify as user cancels. Dropped; rely on the structured
20596
+ // signal (`signal.aborted`) and the standard error class
20597
+ // (`name === 'AbortError'`).
20511
20598
  const aborted = signal?.aborted ||
20512
- (error instanceof Error && (error.name === 'AbortError' || error.message?.includes('aborted')));
20599
+ (error instanceof Error && error.name === 'AbortError');
20513
20600
  if (aborted) {
20514
20601
  throw new LangChainCancelledError(error instanceof Error ? error.message : 'Streaming aborted by user', accumulated, {
20515
20602
  provider: effectiveProvider,
@@ -20768,6 +20855,12 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20768
20855
  // schema-validated retry — paying for a second LLM call only
20769
20856
  // on the edge case where the streamed output is unsalvageable.
20770
20857
  const streamingParser = createSchemaParser(schema, llm);
20858
+ // Capture the final accumulated text out-of-band so we can
20859
+ // attempt salvage if the parser throws on completion (audit
20860
+ // finding #1). Updated on every chunk; the last value is
20861
+ // whatever the stream produced before the parser ran. Empty
20862
+ // string when streaming throws before any chunks arrived.
20863
+ let streamedAccumulated = '';
20771
20864
  let salvaged;
20772
20865
  try {
20773
20866
  // `executeChainStreaming` runs the parser on the accumulated
@@ -20781,6 +20874,7 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20781
20874
  variables: budgetedPrompt.variables,
20782
20875
  parser: streamingParser,
20783
20876
  onChunk: ({ text, accumulated }) => {
20877
+ streamedAccumulated = accumulated;
20784
20878
  onStreamChunk(text, accumulated);
20785
20879
  },
20786
20880
  signal,
@@ -20810,13 +20904,24 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20810
20904
  cancelled: true,
20811
20905
  };
20812
20906
  }
20813
- // Streamed accumulated text didn't parse cleanly. Try the
20814
- // lossy salvager on whatever we have; if that produces a
20815
- // non-placeholder title, accept it. Otherwise fall through
20816
- // to the non-streaming path which can retry with a fresh
20817
- // LLM call.
20818
- logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
20819
- salvaged = undefined;
20907
+ // Audit finding #1: try the lossy salvager on the accumulated
20908
+ // text before paying for a second LLM call. The salvager
20909
+ // strips code fences, attempts strict JSON parse, and falls
20910
+ // back to "first line is title, rest is body." We only accept
20911
+ // its output when it produced a real title — the placeholder
20912
+ // title ("Auto-generated commit") means the salvager
20913
+ // couldn't extract anything meaningful and the non-streaming
20914
+ // retry is the better choice.
20915
+ if (streamedAccumulated) {
20916
+ const candidate = salvageCommitMessageFromText(streamedAccumulated);
20917
+ if (candidate.title && candidate.title !== 'Auto-generated commit') {
20918
+ salvaged = candidate;
20919
+ logger.verbose(`Streaming parser failed but salvager recovered a draft from ${streamedAccumulated.length} accumulated chars; skipping non-streaming retry.`, { color: 'green' });
20920
+ }
20921
+ }
20922
+ if (!salvaged) {
20923
+ logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
20924
+ }
20820
20925
  }
20821
20926
  // Type-narrow: commitMsg is set inside try{}, but TS doesn't
20822
20927
  // see that across the catch. Re-init through the salvage path
@@ -20825,10 +20930,12 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20825
20930
  commitMsg = salvaged;
20826
20931
  }
20827
20932
  else if (!(commitMsg)) {
20828
- // Streaming threw; do the standard non-streaming flow to
20829
- // recover. This is the trade-off documented in the issue —
20830
- // streaming gives us a preview but the validated result still
20831
- // comes from the schema-aware retry path when streaming fails.
20933
+ // Streaming threw AND the salvager couldn't recover anything
20934
+ // useful; fall back to the standard non-streaming flow.
20935
+ // Documented trade-off from the issue: streaming gives us a
20936
+ // preview but the validated result still comes from the
20937
+ // schema-aware retry path when both streaming AND salvage
20938
+ // fail.
20832
20939
  commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
20833
20940
  logger,
20834
20941
  tokenizer,
@@ -24271,10 +24378,14 @@ function applyLogInkAction(state, action) {
24271
24378
  // Cache the result so re-entry (or `c` to PR) reuses it instead of
24272
24379
  // re-running the LLM. Keyed by branch so a checkout naturally
24273
24380
  // produces a fresh generation.
24381
+ // Audit finding #9: `generatedAt` arrives on the action payload
24382
+ // instead of being read from `Date.now()` here, so the reducer
24383
+ // stays pure. Dispatchers (currently `runChangelogView` in
24384
+ // app.ts) call `Date.now()` at dispatch time.
24274
24385
  const cached = {
24275
24386
  text: action.text,
24276
24387
  baseLabel: action.baseLabel,
24277
- generatedAt: Date.now(),
24388
+ generatedAt: action.generatedAt,
24278
24389
  };
24279
24390
  return {
24280
24391
  ...state,
@@ -24328,7 +24439,8 @@ function applyLogInkAction(state, action) {
24328
24439
  // Updated-at timestamp reflects the edit. Not the original
24329
24440
  // generation time — `r` (regenerate) is the explicit knob
24330
24441
  // for "I want fresh LLM output, not my edits".
24331
- generatedAt: Date.now(),
24442
+ // Audit finding #9: timestamp arrives on the action.
24443
+ generatedAt: action.generatedAt,
24332
24444
  },
24333
24445
  },
24334
24446
  pendingKey: undefined,
@@ -24364,7 +24476,9 @@ function applyLogInkAction(state, action) {
24364
24476
  }
24365
24477
  return {
24366
24478
  ...state,
24367
- recentCommitHashes: { hashes: action.hashes, markedAt: Date.now() },
24479
+ // Audit finding #9: timestamp arrives on the action payload
24480
+ // instead of being read from `Date.now()` here.
24481
+ recentCommitHashes: { hashes: action.hashes, markedAt: action.markedAt },
24368
24482
  pendingKey: undefined,
24369
24483
  };
24370
24484
  case 'clearRecentCommits':
@@ -25164,16 +25278,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25164
25278
  return [];
25165
25279
  }
25166
25280
  // Cancel in-flight AI commit draft (#881 phase 3). When the compose
25167
- // surface is mid-stream (loading === true), Esc aborts the LLM call
25168
- // and the runtime handler cleans up (clear loading, clear preview,
25169
- // status line shows "AI draft cancelled."). Sits above the editing
25170
- // / view handlers so the cancel keystroke can't fall through to
25171
- // "leave compose" or anything else.
25281
+ // state has a draft in flight (loading === true), Esc aborts the
25282
+ // LLM call and the runtime handler cleans up (clear loading, clear
25283
+ // preview, status line shows "AI draft cancelled.").
25284
+ //
25285
+ // Audit finding #5: the `activeView === 'compose'` gate from the
25286
+ // original phase 3 implementation made the cancel keystroke
25287
+ // unreachable after the user chord-navigated away from compose
25288
+ // mid-stream (Esc would fall through to popView etc., consuming
25289
+ // the navigation intent while the LLM call silently ran to
25290
+ // completion). Cancel should work wherever the user is — they
25291
+ // can always navigate back to compose afterwards.
25172
25292
  //
25173
- // Loading and editing are mutually exclusive in practice (the user
25174
- // can't type while the AI is generating), but the order here makes
25175
- // the precedence explicit if that ever changes.
25176
- if (state.activeView === 'compose' && state.commitCompose.loading && key.escape) {
25293
+ // Sits above the editing / view handlers so the cancel keystroke
25294
+ // can't fall through to "leave compose" or anything else. Loading
25295
+ // and editing are mutually exclusive in practice (the user can't
25296
+ // type while the AI is generating), but the order here makes the
25297
+ // precedence explicit if that ever changes.
25298
+ if (state.commitCompose.loading && key.escape) {
25177
25299
  return [{ type: 'cancelAiCommitDraft' }];
25178
25300
  }
25179
25301
  // Cancel in-flight PR body draft (#881 phase 4). The `C` keystroke
@@ -25193,6 +25315,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25193
25315
  if (state.pendingPullRequestBodyDraft && key.escape) {
25194
25316
  return [{ type: 'cancelPullRequestBodyDraft' }];
25195
25317
  }
25318
+ // Pending AI draft confirmation (audit finding #7). When the AI
25319
+ // draft completes against a non-empty compose surface, it lands in
25320
+ // `pendingAiDraft` instead of overwriting the user's typing. `R`
25321
+ // accepts the swap (user's typing is lost, AI draft becomes the
25322
+ // new content). `Esc` dismisses the AI draft (typing is preserved,
25323
+ // AI draft is lost — the user paid for the tokens but explicitly
25324
+ // chose not to use them).
25325
+ //
25326
+ // Gated on `activeView === 'compose'` because the pending draft is
25327
+ // only meaningful on the compose surface (where the message line
25328
+ // surfaces the prompt). A user who chord-navigated away while the
25329
+ // draft was pending should see the original `R` / Esc semantics of
25330
+ // wherever they are now.
25331
+ if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
25332
+ if (inputValue === 'R' && !key.ctrl && !key.meta) {
25333
+ return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
25334
+ }
25335
+ if (key.escape) {
25336
+ return [action({ type: 'commitCompose', action: { type: 'dismissPendingAiDraft' } })];
25337
+ }
25338
+ }
25196
25339
  if (state.commitCompose.editing) {
25197
25340
  if (key.escape) {
25198
25341
  return [action({ type: 'commitCompose', action: { type: 'setEditing', value: false } })];
@@ -36251,6 +36394,14 @@ function LogInkApp(deps) {
36251
36394
  // workdirs for submodule paths recorded in `.gitmodules` (which
36252
36395
  // are repo-relative). Undefined during the brief moment between
36253
36396
  // git swap and the revparse callback resolving.
36397
+ //
36398
+ // Audit finding #10: rapid frame push/pop races are prevented by
36399
+ // the per-effect `cancelled` flag — React fires the cleanup
36400
+ // synchronously BEFORE running the next effect body, so any
36401
+ // pending revparse from the old `git` sees `cancelled === true`
36402
+ // and skips its write. The `git` reference itself is captured by
36403
+ // closure, so each effect run resolves against the right binding.
36404
+ // No additional depth tagging is needed.
36254
36405
  const [activeRepoRoot, setActiveRepoRoot] = React.useState(undefined);
36255
36406
  React.useEffect(() => {
36256
36407
  let cancelled = false;
@@ -36681,18 +36832,50 @@ function LogInkApp(deps) {
36681
36832
  })();
36682
36833
  return () => { cancelled = true; };
36683
36834
  }, [git, dispatch]);
36835
+ // Audit finding #2: re-resolve the repo root inline on every save
36836
+ // and key the deps off `git` + the saved value. The original
36837
+ // implementation read from `repoRootRef.current`, which is async-
36838
+ // populated by the resolver effect above and can lag behind a git
36839
+ // swap. After #995's synchronous pop-restore, the parent's freshly
36840
+ // restored sidebar tab was being written into the submodule's
36841
+ // cache because the ref still held the submodule root during the
36842
+ // brief window before the resolver settled.
36843
+ //
36844
+ // The extra `revparse` cost per save is negligible (saves fire
36845
+ // once per user-initiated tab change, not per render) and the
36846
+ // cancellation flag prevents a stale resolution from racing a
36847
+ // newer one in flight.
36684
36848
  React.useEffect(() => {
36685
- const repoRoot = repoRootRef.current;
36686
- if (!repoRoot)
36687
- return;
36688
- saveSidebarTab(repoRoot, state.userSidebarTab);
36689
- }, [state.userSidebarTab]);
36849
+ let cancelled = false;
36850
+ void (async () => {
36851
+ try {
36852
+ const root = (await git.revparse(['--show-toplevel'])).trim();
36853
+ if (cancelled || !root)
36854
+ return;
36855
+ saveSidebarTab(root, state.userSidebarTab);
36856
+ }
36857
+ catch {
36858
+ // Not in a worktree, or revparse failed — silently skip.
36859
+ // The next save attempt will retry.
36860
+ }
36861
+ })();
36862
+ return () => { cancelled = true; };
36863
+ }, [state.userSidebarTab, git]);
36690
36864
  React.useEffect(() => {
36691
- const repoRoot = repoRootRef.current;
36692
- if (!repoRoot)
36693
- return;
36694
- saveDiffViewMode(repoRoot, state.diffViewMode);
36695
- }, [state.diffViewMode]);
36865
+ let cancelled = false;
36866
+ void (async () => {
36867
+ try {
36868
+ const root = (await git.revparse(['--show-toplevel'])).trim();
36869
+ if (cancelled || !root)
36870
+ return;
36871
+ saveDiffViewMode(root, state.diffViewMode);
36872
+ }
36873
+ catch {
36874
+ // Same as above.
36875
+ }
36876
+ })();
36877
+ return () => { cancelled = true; };
36878
+ }, [state.diffViewMode, git]);
36696
36879
  // P-stash-explorer: load `git stash show -p <ref>` once the diff view
36697
36880
  // becomes active with diffSource='stash'. Best-effort — empty stashes
36698
36881
  // or read errors fall through to a "no diff" hint at the render site.
@@ -37326,6 +37509,12 @@ function LogInkApp(deps) {
37326
37509
  git,
37327
37510
  signal: controller.signal,
37328
37511
  onStreamChunk: (_text, accumulated) => {
37512
+ // Audit finding #4: skip dispatching into a torn-down
37513
+ // tree. If the user quit (or otherwise unmounted the
37514
+ // workstation) mid-stream, React warns about updates on
37515
+ // an unmounted component. Drop the chunk silently.
37516
+ if (!mountedRef.current)
37517
+ return;
37329
37518
  // Dispatch the full accumulated text — the preview chrome
37330
37519
  // helper does the last-N-lines slicing at render time, so
37331
37520
  // re-doing the slice here would be wasted work. Per-chunk
@@ -37337,6 +37526,11 @@ function LogInkApp(deps) {
37337
37526
  });
37338
37527
  },
37339
37528
  });
37529
+ // Audit finding #4 (unmount race): bail out before any
37530
+ // post-await dispatch if the user quit while the LLM call was
37531
+ // in flight. Same pattern as `refreshHistoryRows` upstream.
37532
+ if (!mountedRef.current)
37533
+ return;
37340
37534
  // Cancel path (#881 phase 3). User pressed Esc during the
37341
37535
  // stream; reducer drops loading + preview, status line shows
37342
37536
  // a neutral "cancelled" message. Skip the result / failure
@@ -37357,6 +37551,23 @@ function LogInkApp(deps) {
37357
37551
  });
37358
37552
  dispatch({ type: 'setStatus', value: result.message });
37359
37553
  }
37554
+ catch (error) {
37555
+ // Audit finding #3: defensive recovery for unexpected throws
37556
+ // from the workflow. The workflow catches its own errors
37557
+ // today, so this catch is latent — but any future refactor
37558
+ // that lets an error escape would otherwise strand the
37559
+ // spinner permanently with no user-facing recovery short of
37560
+ // quitting. Surface a generic failure and clear the loading
37561
+ // state so the user can re-try.
37562
+ if (mountedRef.current) {
37563
+ dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
37564
+ dispatch({
37565
+ type: 'setStatus',
37566
+ value: `AI draft failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
37567
+ kind: 'error',
37568
+ });
37569
+ }
37570
+ }
37360
37571
  finally {
37361
37572
  // Clear the ref only if it still points at OUR controller — a
37362
37573
  // rapid second invocation could have already replaced it, in
@@ -37448,9 +37659,14 @@ function LogInkApp(deps) {
37448
37659
  const cancelHandle = { cancelled: false };
37449
37660
  pullRequestBodyCancelRef.current = cancelHandle;
37450
37661
  dispatch({ type: 'setPendingPullRequestBodyDraft', value: true });
37662
+ // Audit finding #6: soft cancel today — Esc skips opening the
37663
+ // follow-up prompt, but the LLM call itself keeps running to
37664
+ // completion (no AbortSignal threaded through the changelog CLI
37665
+ // chain). Status copy reflects that honestly so the user isn't
37666
+ // misled into thinking they're saving tokens.
37451
37667
  dispatch({
37452
37668
  type: 'setStatus',
37453
- value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to cancel`,
37669
+ value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to skip prompt`,
37454
37670
  loading: true,
37455
37671
  });
37456
37672
  try {
@@ -37477,6 +37693,15 @@ function LogInkApp(deps) {
37477
37693
  else {
37478
37694
  dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
37479
37695
  }
37696
+ // Audit finding #11: clear the pending flag BEFORE opening the
37697
+ // prompt. If a future refactor adds an `await` between the flag
37698
+ // clear (currently in `finally`) and the `openInputPrompt`
37699
+ // dispatch, an Esc keystroke in the gap would dispatch
37700
+ // `cancelPullRequestBodyDraft` AFTER the prompt opens, leaving
37701
+ // the prompt visible with a stale "cancelled" message. Clearing
37702
+ // here moves the flag teardown into the same React batch as the
37703
+ // prompt open, eliminating the race.
37704
+ dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
37480
37705
  dispatch({
37481
37706
  type: 'openInputPrompt',
37482
37707
  kind: 'create-pr',
@@ -37486,11 +37711,14 @@ function LogInkApp(deps) {
37486
37711
  });
37487
37712
  }
37488
37713
  finally {
37489
- // Clear the flag + the ref so a subsequent draft starts clean.
37714
+ // Belt-and-suspenders: the `try` block clears the flag on the
37715
+ // success path (audit finding #11). This duplicate clear handles
37716
+ // the error / cancel paths where the early-returns skip the
37717
+ // success-path dispatch. Safe to no-op when already false.
37718
+ dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
37490
37719
  // Only clear the ref if we still own it — a second invocation
37491
37720
  // would have already taken ownership in which case the cancel
37492
37721
  // duty has rolled over.
37493
- dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
37494
37722
  if (pullRequestBodyCancelRef.current === cancelHandle) {
37495
37723
  pullRequestBodyCancelRef.current = null;
37496
37724
  }
@@ -37591,6 +37819,11 @@ function LogInkApp(deps) {
37591
37819
  branch: head,
37592
37820
  baseLabel: cached.baseLabel,
37593
37821
  text: cached.text,
37822
+ // Audit finding #9: cache-hit path preserves the original
37823
+ // generation timestamp rather than minting a fresh one — the
37824
+ // "X ago" header should reflect when the LLM ran, not when
37825
+ // the cached entry was re-displayed.
37826
+ generatedAt: cached.generatedAt,
37594
37827
  });
37595
37828
  dispatch({
37596
37829
  type: 'setStatus',
@@ -37619,6 +37852,9 @@ function LogInkApp(deps) {
37619
37852
  branch: head,
37620
37853
  baseLabel,
37621
37854
  text: result.text,
37855
+ // Audit finding #9: timestamp captured at dispatch time, not
37856
+ // inside the reducer.
37857
+ generatedAt: Date.now(),
37622
37858
  });
37623
37859
  dispatch({
37624
37860
  type: 'setStatus',
@@ -37721,7 +37957,7 @@ function LogInkApp(deps) {
37721
37957
  if (editorOk) {
37722
37958
  try {
37723
37959
  const content = readFileSync$1(file, 'utf8');
37724
- dispatch({ type: 'setChangelogText', text: content });
37960
+ dispatch({ type: 'setChangelogText', text: content, generatedAt: Date.now() });
37725
37961
  dispatch({ type: 'setStatus', value: 'Changelog updated from editor.' });
37726
37962
  }
37727
37963
  catch (error) {
@@ -38076,7 +38312,8 @@ function LogInkApp(deps) {
38076
38312
  // that could disagree with reality on partial-apply.
38077
38313
  const commitHashes = result.commitHashes || [];
38078
38314
  if (commitHashes.length > 0) {
38079
- dispatch({ type: 'markRecentCommits', hashes: commitHashes });
38315
+ // Audit finding #9: timestamp captured at dispatch time.
38316
+ dispatch({ type: 'markRecentCommits', hashes: commitHashes, markedAt: Date.now() });
38080
38317
  // DevSkim: ignore DS172411 — function literal, fixed delay,
38081
38318
  // no caller-supplied data flowing through.
38082
38319
  setTimeout(() => dispatch({ type: 'clearRecentCommits' }), 5000);
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
78
78
  /**
79
79
  * Current build version from package.json
80
80
  */
81
- const BUILD_VERSION = "0.54.0";
81
+ const BUILD_VERSION = "0.54.1";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -20237,9 +20237,16 @@ function applyCommitComposeAction(state, action) {
20237
20237
  field: state.field === 'summary' ? 'body' : 'summary',
20238
20238
  };
20239
20239
  case 'setEditing':
20240
+ // Audit finding #12: defensively clear `streamingPreview` when
20241
+ // editing toggles off AND no draft is in flight. The current
20242
+ // input pipeline never triggers this combination, but the
20243
+ // reducer is the source of truth — if a future code path
20244
+ // toggles editing off mid-stream, the preview shouldn't linger
20245
+ // below an idle compose panel.
20240
20246
  return {
20241
20247
  ...state,
20242
20248
  editing: action.value,
20249
+ streamingPreview: !action.value && !state.loading ? undefined : state.streamingPreview,
20243
20250
  };
20244
20251
  case 'setLoading':
20245
20252
  // Clearing loading also clears any in-flight streaming preview;
@@ -20251,6 +20258,22 @@ function applyCommitComposeAction(state, action) {
20251
20258
  streamingPreview: action.value ? state.streamingPreview : undefined,
20252
20259
  };
20253
20260
  case 'setDraft':
20261
+ // Audit finding #7: if the user has typed content in summary or
20262
+ // body, the AI draft would silently clobber their work with no
20263
+ // undo. Route the result to `pendingAiDraft` instead and surface
20264
+ // a confirmation message; the user accepts with `R` (replace)
20265
+ // or dismisses with Esc. Empty fields = safe to replace as
20266
+ // before, since there's nothing to lose.
20267
+ if (state.summary.trim() || state.body.trim()) {
20268
+ return {
20269
+ ...state,
20270
+ loading: false,
20271
+ streamingPreview: undefined,
20272
+ pendingAiDraft: action.value,
20273
+ message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
20274
+ details: undefined,
20275
+ };
20276
+ }
20254
20277
  // No `message` here — the loader → filled fields are the confirmation
20255
20278
  // that the AI generated something. A lingering "AI draft ready for
20256
20279
  // editing" line in the panel reads as stale state. The runtime still
@@ -20265,6 +20288,7 @@ function applyCommitComposeAction(state, action) {
20265
20288
  message: undefined,
20266
20289
  details: undefined,
20267
20290
  streamingPreview: undefined,
20291
+ pendingAiDraft: undefined,
20268
20292
  };
20269
20293
  case 'setResult':
20270
20294
  return {
@@ -20284,6 +20308,46 @@ function applyCommitComposeAction(state, action) {
20284
20308
  ...state,
20285
20309
  streamingPreview: action.value,
20286
20310
  };
20311
+ case 'setPendingAiDraft':
20312
+ // Audit finding #7: route the AI draft here (instead of straight
20313
+ // to summary/body via `setDraft`) when the user has unsaved
20314
+ // typing the draft would clobber. The dispatcher does the
20315
+ // user-content check; this reducer just stashes the draft and
20316
+ // surfaces a message inviting the user to accept or dismiss.
20317
+ return {
20318
+ ...state,
20319
+ loading: false,
20320
+ streamingPreview: undefined,
20321
+ pendingAiDraft: action.value,
20322
+ message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
20323
+ details: undefined,
20324
+ };
20325
+ case 'acceptPendingAiDraft':
20326
+ // Swap the pending draft into the editable fields and clear it.
20327
+ // Mirrors `setDraft`'s field positioning (focus on summary,
20328
+ // editing on) so the user lands in the same place whether they
20329
+ // accepted immediately or after deliberation.
20330
+ if (!state.pendingAiDraft)
20331
+ return state;
20332
+ return {
20333
+ ...state,
20334
+ ...splitCommitDraft(state.pendingAiDraft),
20335
+ field: 'summary',
20336
+ editing: true,
20337
+ loading: false,
20338
+ message: undefined,
20339
+ details: undefined,
20340
+ streamingPreview: undefined,
20341
+ pendingAiDraft: undefined,
20342
+ };
20343
+ case 'dismissPendingAiDraft':
20344
+ // User chose to keep their typing; drop the AI draft.
20345
+ return {
20346
+ ...state,
20347
+ pendingAiDraft: undefined,
20348
+ message: undefined,
20349
+ details: undefined,
20350
+ };
20287
20351
  case 'reset':
20288
20352
  // Drop message/details too — the post-commit "Created commit ..."
20289
20353
  // notification is already on the runtime status line (footer); a
@@ -20471,6 +20535,14 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
20471
20535
  // classify below.
20472
20536
  const stream = await chain.stream(variables, signal ? { signal } : undefined);
20473
20537
  let chunkCount = 0;
20538
+ let callbackFailureCount = 0;
20539
+ // Audit finding #13: cap consecutive callback failures so a
20540
+ // genuinely broken render handler can't tie up the LLM call
20541
+ // silently for the user's entire wait. Five strikes (out of an
20542
+ // expected ~50-500 chunks for a normal commit message) is enough
20543
+ // to ride out a transient blip but small enough to bail before
20544
+ // the user finishes waiting on a useless stream.
20545
+ const MAX_CALLBACK_FAILURES = 5;
20474
20546
  for await (const messageChunk of stream) {
20475
20547
  const text = coerceChunkText(messageChunk);
20476
20548
  if (!text)
@@ -20479,12 +20551,20 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
20479
20551
  chunkCount += 1;
20480
20552
  try {
20481
20553
  onChunk({ text, accumulated });
20554
+ // Successful callback resets the consecutive-failure counter —
20555
+ // we only bail on a STREAK of failures, not on isolated ones.
20556
+ callbackFailureCount = 0;
20482
20557
  }
20483
20558
  catch (callbackError) {
20484
20559
  // Deliberately swallow callback errors so a bad render handler
20485
20560
  // can't tank the entire LLM call. Log at verbose so users with
20486
20561
  // verbose mode on can still see what happened.
20487
- logger?.verbose(`executeChainStreaming: onChunk handler threw: ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
20562
+ callbackFailureCount += 1;
20563
+ logger?.verbose(`executeChainStreaming: onChunk handler threw (${callbackFailureCount}/${MAX_CALLBACK_FAILURES}): ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
20564
+ if (callbackFailureCount >= MAX_CALLBACK_FAILURES) {
20565
+ logger?.verbose(`executeChainStreaming: bailing stream — ${MAX_CALLBACK_FAILURES} consecutive callback failures suggest a broken render handler.`, { color: 'red' });
20566
+ throw new LangChainExecutionError(`executeChainStreaming: render handler failed ${MAX_CALLBACK_FAILURES} times in a row; aborting stream so the failure surfaces to the caller.`, { accumulatedLength: accumulated.length, chunkCount });
20567
+ }
20488
20568
  }
20489
20569
  }
20490
20570
  if (!accumulated) {
@@ -20518,15 +20598,22 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
20518
20598
  }
20519
20599
  catch (error) {
20520
20600
  // Cancellation classifier (#881 phase 3). Three signals: an
20521
- // explicitly aborted user signal (post-throw check), the
20522
- // standard DOM `AbortError`, or a Node `AbortSignal` with
20523
- // `signal.aborted === true` while a chain-internal error
20524
- // propagates. Any of these means "user wanted out," not "the
20525
- // call failed." Wrap the raw error so callers can pattern-match
20526
- // on `LangChainCancelledError` and carry the partial accumulated
20527
- // text in case the caller wants to salvage anything.
20601
+ // explicitly aborted user signal (post-throw check) or a thrown
20602
+ // `AbortError` from the standard DOM API. Either means "user
20603
+ // wanted out," not "the call failed." Wrap the raw error so
20604
+ // callers can pattern-match on `LangChainCancelledError` and
20605
+ // carry the partial accumulated text in case the caller wants
20606
+ // to salvage anything.
20607
+ //
20608
+ // Audit finding #8: an earlier implementation also fell back to
20609
+ // `error.message.includes('aborted')` as a third signal. That
20610
+ // substring heuristic is footgun-shaped — legitimate provider
20611
+ // errors ("model not aborted properly", future API copy) would
20612
+ // misclassify as user cancels. Dropped; rely on the structured
20613
+ // signal (`signal.aborted`) and the standard error class
20614
+ // (`name === 'AbortError'`).
20528
20615
  const aborted = signal?.aborted ||
20529
- (error instanceof Error && (error.name === 'AbortError' || error.message?.includes('aborted')));
20616
+ (error instanceof Error && error.name === 'AbortError');
20530
20617
  if (aborted) {
20531
20618
  throw new LangChainCancelledError(error instanceof Error ? error.message : 'Streaming aborted by user', accumulated, {
20532
20619
  provider: effectiveProvider,
@@ -20785,6 +20872,12 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20785
20872
  // schema-validated retry — paying for a second LLM call only
20786
20873
  // on the edge case where the streamed output is unsalvageable.
20787
20874
  const streamingParser = createSchemaParser(schema, llm);
20875
+ // Capture the final accumulated text out-of-band so we can
20876
+ // attempt salvage if the parser throws on completion (audit
20877
+ // finding #1). Updated on every chunk; the last value is
20878
+ // whatever the stream produced before the parser ran. Empty
20879
+ // string when streaming throws before any chunks arrived.
20880
+ let streamedAccumulated = '';
20788
20881
  let salvaged;
20789
20882
  try {
20790
20883
  // `executeChainStreaming` runs the parser on the accumulated
@@ -20798,6 +20891,7 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20798
20891
  variables: budgetedPrompt.variables,
20799
20892
  parser: streamingParser,
20800
20893
  onChunk: ({ text, accumulated }) => {
20894
+ streamedAccumulated = accumulated;
20801
20895
  onStreamChunk(text, accumulated);
20802
20896
  },
20803
20897
  signal,
@@ -20827,13 +20921,24 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20827
20921
  cancelled: true,
20828
20922
  };
20829
20923
  }
20830
- // Streamed accumulated text didn't parse cleanly. Try the
20831
- // lossy salvager on whatever we have; if that produces a
20832
- // non-placeholder title, accept it. Otherwise fall through
20833
- // to the non-streaming path which can retry with a fresh
20834
- // LLM call.
20835
- logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
20836
- salvaged = undefined;
20924
+ // Audit finding #1: try the lossy salvager on the accumulated
20925
+ // text before paying for a second LLM call. The salvager
20926
+ // strips code fences, attempts strict JSON parse, and falls
20927
+ // back to "first line is title, rest is body." We only accept
20928
+ // its output when it produced a real title — the placeholder
20929
+ // title ("Auto-generated commit") means the salvager
20930
+ // couldn't extract anything meaningful and the non-streaming
20931
+ // retry is the better choice.
20932
+ if (streamedAccumulated) {
20933
+ const candidate = salvageCommitMessageFromText(streamedAccumulated);
20934
+ if (candidate.title && candidate.title !== 'Auto-generated commit') {
20935
+ salvaged = candidate;
20936
+ logger.verbose(`Streaming parser failed but salvager recovered a draft from ${streamedAccumulated.length} accumulated chars; skipping non-streaming retry.`, { color: 'green' });
20937
+ }
20938
+ }
20939
+ if (!salvaged) {
20940
+ logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
20941
+ }
20837
20942
  }
20838
20943
  // Type-narrow: commitMsg is set inside try{}, but TS doesn't
20839
20944
  // see that across the catch. Re-init through the salvage path
@@ -20842,10 +20947,12 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20842
20947
  commitMsg = salvaged;
20843
20948
  }
20844
20949
  else if (!(commitMsg)) {
20845
- // Streaming threw; do the standard non-streaming flow to
20846
- // recover. This is the trade-off documented in the issue —
20847
- // streaming gives us a preview but the validated result still
20848
- // comes from the schema-aware retry path when streaming fails.
20950
+ // Streaming threw AND the salvager couldn't recover anything
20951
+ // useful; fall back to the standard non-streaming flow.
20952
+ // Documented trade-off from the issue: streaming gives us a
20953
+ // preview but the validated result still comes from the
20954
+ // schema-aware retry path when both streaming AND salvage
20955
+ // fail.
20849
20956
  commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
20850
20957
  logger,
20851
20958
  tokenizer,
@@ -24288,10 +24395,14 @@ function applyLogInkAction(state, action) {
24288
24395
  // Cache the result so re-entry (or `c` to PR) reuses it instead of
24289
24396
  // re-running the LLM. Keyed by branch so a checkout naturally
24290
24397
  // produces a fresh generation.
24398
+ // Audit finding #9: `generatedAt` arrives on the action payload
24399
+ // instead of being read from `Date.now()` here, so the reducer
24400
+ // stays pure. Dispatchers (currently `runChangelogView` in
24401
+ // app.ts) call `Date.now()` at dispatch time.
24291
24402
  const cached = {
24292
24403
  text: action.text,
24293
24404
  baseLabel: action.baseLabel,
24294
- generatedAt: Date.now(),
24405
+ generatedAt: action.generatedAt,
24295
24406
  };
24296
24407
  return {
24297
24408
  ...state,
@@ -24345,7 +24456,8 @@ function applyLogInkAction(state, action) {
24345
24456
  // Updated-at timestamp reflects the edit. Not the original
24346
24457
  // generation time — `r` (regenerate) is the explicit knob
24347
24458
  // for "I want fresh LLM output, not my edits".
24348
- generatedAt: Date.now(),
24459
+ // Audit finding #9: timestamp arrives on the action.
24460
+ generatedAt: action.generatedAt,
24349
24461
  },
24350
24462
  },
24351
24463
  pendingKey: undefined,
@@ -24381,7 +24493,9 @@ function applyLogInkAction(state, action) {
24381
24493
  }
24382
24494
  return {
24383
24495
  ...state,
24384
- recentCommitHashes: { hashes: action.hashes, markedAt: Date.now() },
24496
+ // Audit finding #9: timestamp arrives on the action payload
24497
+ // instead of being read from `Date.now()` here.
24498
+ recentCommitHashes: { hashes: action.hashes, markedAt: action.markedAt },
24385
24499
  pendingKey: undefined,
24386
24500
  };
24387
24501
  case 'clearRecentCommits':
@@ -25181,16 +25295,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25181
25295
  return [];
25182
25296
  }
25183
25297
  // Cancel in-flight AI commit draft (#881 phase 3). When the compose
25184
- // surface is mid-stream (loading === true), Esc aborts the LLM call
25185
- // and the runtime handler cleans up (clear loading, clear preview,
25186
- // status line shows "AI draft cancelled."). Sits above the editing
25187
- // / view handlers so the cancel keystroke can't fall through to
25188
- // "leave compose" or anything else.
25298
+ // state has a draft in flight (loading === true), Esc aborts the
25299
+ // LLM call and the runtime handler cleans up (clear loading, clear
25300
+ // preview, status line shows "AI draft cancelled.").
25301
+ //
25302
+ // Audit finding #5: the `activeView === 'compose'` gate from the
25303
+ // original phase 3 implementation made the cancel keystroke
25304
+ // unreachable after the user chord-navigated away from compose
25305
+ // mid-stream (Esc would fall through to popView etc., consuming
25306
+ // the navigation intent while the LLM call silently ran to
25307
+ // completion). Cancel should work wherever the user is — they
25308
+ // can always navigate back to compose afterwards.
25189
25309
  //
25190
- // Loading and editing are mutually exclusive in practice (the user
25191
- // can't type while the AI is generating), but the order here makes
25192
- // the precedence explicit if that ever changes.
25193
- if (state.activeView === 'compose' && state.commitCompose.loading && key.escape) {
25310
+ // Sits above the editing / view handlers so the cancel keystroke
25311
+ // can't fall through to "leave compose" or anything else. Loading
25312
+ // and editing are mutually exclusive in practice (the user can't
25313
+ // type while the AI is generating), but the order here makes the
25314
+ // precedence explicit if that ever changes.
25315
+ if (state.commitCompose.loading && key.escape) {
25194
25316
  return [{ type: 'cancelAiCommitDraft' }];
25195
25317
  }
25196
25318
  // Cancel in-flight PR body draft (#881 phase 4). The `C` keystroke
@@ -25210,6 +25332,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25210
25332
  if (state.pendingPullRequestBodyDraft && key.escape) {
25211
25333
  return [{ type: 'cancelPullRequestBodyDraft' }];
25212
25334
  }
25335
+ // Pending AI draft confirmation (audit finding #7). When the AI
25336
+ // draft completes against a non-empty compose surface, it lands in
25337
+ // `pendingAiDraft` instead of overwriting the user's typing. `R`
25338
+ // accepts the swap (user's typing is lost, AI draft becomes the
25339
+ // new content). `Esc` dismisses the AI draft (typing is preserved,
25340
+ // AI draft is lost — the user paid for the tokens but explicitly
25341
+ // chose not to use them).
25342
+ //
25343
+ // Gated on `activeView === 'compose'` because the pending draft is
25344
+ // only meaningful on the compose surface (where the message line
25345
+ // surfaces the prompt). A user who chord-navigated away while the
25346
+ // draft was pending should see the original `R` / Esc semantics of
25347
+ // wherever they are now.
25348
+ if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
25349
+ if (inputValue === 'R' && !key.ctrl && !key.meta) {
25350
+ return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
25351
+ }
25352
+ if (key.escape) {
25353
+ return [action({ type: 'commitCompose', action: { type: 'dismissPendingAiDraft' } })];
25354
+ }
25355
+ }
25213
25356
  if (state.commitCompose.editing) {
25214
25357
  if (key.escape) {
25215
25358
  return [action({ type: 'commitCompose', action: { type: 'setEditing', value: false } })];
@@ -36268,6 +36411,14 @@ function LogInkApp(deps) {
36268
36411
  // workdirs for submodule paths recorded in `.gitmodules` (which
36269
36412
  // are repo-relative). Undefined during the brief moment between
36270
36413
  // git swap and the revparse callback resolving.
36414
+ //
36415
+ // Audit finding #10: rapid frame push/pop races are prevented by
36416
+ // the per-effect `cancelled` flag — React fires the cleanup
36417
+ // synchronously BEFORE running the next effect body, so any
36418
+ // pending revparse from the old `git` sees `cancelled === true`
36419
+ // and skips its write. The `git` reference itself is captured by
36420
+ // closure, so each effect run resolves against the right binding.
36421
+ // No additional depth tagging is needed.
36271
36422
  const [activeRepoRoot, setActiveRepoRoot] = React.useState(undefined);
36272
36423
  React.useEffect(() => {
36273
36424
  let cancelled = false;
@@ -36698,18 +36849,50 @@ function LogInkApp(deps) {
36698
36849
  })();
36699
36850
  return () => { cancelled = true; };
36700
36851
  }, [git, dispatch]);
36852
+ // Audit finding #2: re-resolve the repo root inline on every save
36853
+ // and key the deps off `git` + the saved value. The original
36854
+ // implementation read from `repoRootRef.current`, which is async-
36855
+ // populated by the resolver effect above and can lag behind a git
36856
+ // swap. After #995's synchronous pop-restore, the parent's freshly
36857
+ // restored sidebar tab was being written into the submodule's
36858
+ // cache because the ref still held the submodule root during the
36859
+ // brief window before the resolver settled.
36860
+ //
36861
+ // The extra `revparse` cost per save is negligible (saves fire
36862
+ // once per user-initiated tab change, not per render) and the
36863
+ // cancellation flag prevents a stale resolution from racing a
36864
+ // newer one in flight.
36701
36865
  React.useEffect(() => {
36702
- const repoRoot = repoRootRef.current;
36703
- if (!repoRoot)
36704
- return;
36705
- saveSidebarTab(repoRoot, state.userSidebarTab);
36706
- }, [state.userSidebarTab]);
36866
+ let cancelled = false;
36867
+ void (async () => {
36868
+ try {
36869
+ const root = (await git.revparse(['--show-toplevel'])).trim();
36870
+ if (cancelled || !root)
36871
+ return;
36872
+ saveSidebarTab(root, state.userSidebarTab);
36873
+ }
36874
+ catch {
36875
+ // Not in a worktree, or revparse failed — silently skip.
36876
+ // The next save attempt will retry.
36877
+ }
36878
+ })();
36879
+ return () => { cancelled = true; };
36880
+ }, [state.userSidebarTab, git]);
36707
36881
  React.useEffect(() => {
36708
- const repoRoot = repoRootRef.current;
36709
- if (!repoRoot)
36710
- return;
36711
- saveDiffViewMode(repoRoot, state.diffViewMode);
36712
- }, [state.diffViewMode]);
36882
+ let cancelled = false;
36883
+ void (async () => {
36884
+ try {
36885
+ const root = (await git.revparse(['--show-toplevel'])).trim();
36886
+ if (cancelled || !root)
36887
+ return;
36888
+ saveDiffViewMode(root, state.diffViewMode);
36889
+ }
36890
+ catch {
36891
+ // Same as above.
36892
+ }
36893
+ })();
36894
+ return () => { cancelled = true; };
36895
+ }, [state.diffViewMode, git]);
36713
36896
  // P-stash-explorer: load `git stash show -p <ref>` once the diff view
36714
36897
  // becomes active with diffSource='stash'. Best-effort — empty stashes
36715
36898
  // or read errors fall through to a "no diff" hint at the render site.
@@ -37343,6 +37526,12 @@ function LogInkApp(deps) {
37343
37526
  git,
37344
37527
  signal: controller.signal,
37345
37528
  onStreamChunk: (_text, accumulated) => {
37529
+ // Audit finding #4: skip dispatching into a torn-down
37530
+ // tree. If the user quit (or otherwise unmounted the
37531
+ // workstation) mid-stream, React warns about updates on
37532
+ // an unmounted component. Drop the chunk silently.
37533
+ if (!mountedRef.current)
37534
+ return;
37346
37535
  // Dispatch the full accumulated text — the preview chrome
37347
37536
  // helper does the last-N-lines slicing at render time, so
37348
37537
  // re-doing the slice here would be wasted work. Per-chunk
@@ -37354,6 +37543,11 @@ function LogInkApp(deps) {
37354
37543
  });
37355
37544
  },
37356
37545
  });
37546
+ // Audit finding #4 (unmount race): bail out before any
37547
+ // post-await dispatch if the user quit while the LLM call was
37548
+ // in flight. Same pattern as `refreshHistoryRows` upstream.
37549
+ if (!mountedRef.current)
37550
+ return;
37357
37551
  // Cancel path (#881 phase 3). User pressed Esc during the
37358
37552
  // stream; reducer drops loading + preview, status line shows
37359
37553
  // a neutral "cancelled" message. Skip the result / failure
@@ -37374,6 +37568,23 @@ function LogInkApp(deps) {
37374
37568
  });
37375
37569
  dispatch({ type: 'setStatus', value: result.message });
37376
37570
  }
37571
+ catch (error) {
37572
+ // Audit finding #3: defensive recovery for unexpected throws
37573
+ // from the workflow. The workflow catches its own errors
37574
+ // today, so this catch is latent — but any future refactor
37575
+ // that lets an error escape would otherwise strand the
37576
+ // spinner permanently with no user-facing recovery short of
37577
+ // quitting. Surface a generic failure and clear the loading
37578
+ // state so the user can re-try.
37579
+ if (mountedRef.current) {
37580
+ dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
37581
+ dispatch({
37582
+ type: 'setStatus',
37583
+ value: `AI draft failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
37584
+ kind: 'error',
37585
+ });
37586
+ }
37587
+ }
37377
37588
  finally {
37378
37589
  // Clear the ref only if it still points at OUR controller — a
37379
37590
  // rapid second invocation could have already replaced it, in
@@ -37465,9 +37676,14 @@ function LogInkApp(deps) {
37465
37676
  const cancelHandle = { cancelled: false };
37466
37677
  pullRequestBodyCancelRef.current = cancelHandle;
37467
37678
  dispatch({ type: 'setPendingPullRequestBodyDraft', value: true });
37679
+ // Audit finding #6: soft cancel today — Esc skips opening the
37680
+ // follow-up prompt, but the LLM call itself keeps running to
37681
+ // completion (no AbortSignal threaded through the changelog CLI
37682
+ // chain). Status copy reflects that honestly so the user isn't
37683
+ // misled into thinking they're saving tokens.
37468
37684
  dispatch({
37469
37685
  type: 'setStatus',
37470
- value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to cancel`,
37686
+ value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to skip prompt`,
37471
37687
  loading: true,
37472
37688
  });
37473
37689
  try {
@@ -37494,6 +37710,15 @@ function LogInkApp(deps) {
37494
37710
  else {
37495
37711
  dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
37496
37712
  }
37713
+ // Audit finding #11: clear the pending flag BEFORE opening the
37714
+ // prompt. If a future refactor adds an `await` between the flag
37715
+ // clear (currently in `finally`) and the `openInputPrompt`
37716
+ // dispatch, an Esc keystroke in the gap would dispatch
37717
+ // `cancelPullRequestBodyDraft` AFTER the prompt opens, leaving
37718
+ // the prompt visible with a stale "cancelled" message. Clearing
37719
+ // here moves the flag teardown into the same React batch as the
37720
+ // prompt open, eliminating the race.
37721
+ dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
37497
37722
  dispatch({
37498
37723
  type: 'openInputPrompt',
37499
37724
  kind: 'create-pr',
@@ -37503,11 +37728,14 @@ function LogInkApp(deps) {
37503
37728
  });
37504
37729
  }
37505
37730
  finally {
37506
- // Clear the flag + the ref so a subsequent draft starts clean.
37731
+ // Belt-and-suspenders: the `try` block clears the flag on the
37732
+ // success path (audit finding #11). This duplicate clear handles
37733
+ // the error / cancel paths where the early-returns skip the
37734
+ // success-path dispatch. Safe to no-op when already false.
37735
+ dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
37507
37736
  // Only clear the ref if we still own it — a second invocation
37508
37737
  // would have already taken ownership in which case the cancel
37509
37738
  // duty has rolled over.
37510
- dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
37511
37739
  if (pullRequestBodyCancelRef.current === cancelHandle) {
37512
37740
  pullRequestBodyCancelRef.current = null;
37513
37741
  }
@@ -37608,6 +37836,11 @@ function LogInkApp(deps) {
37608
37836
  branch: head,
37609
37837
  baseLabel: cached.baseLabel,
37610
37838
  text: cached.text,
37839
+ // Audit finding #9: cache-hit path preserves the original
37840
+ // generation timestamp rather than minting a fresh one — the
37841
+ // "X ago" header should reflect when the LLM ran, not when
37842
+ // the cached entry was re-displayed.
37843
+ generatedAt: cached.generatedAt,
37611
37844
  });
37612
37845
  dispatch({
37613
37846
  type: 'setStatus',
@@ -37636,6 +37869,9 @@ function LogInkApp(deps) {
37636
37869
  branch: head,
37637
37870
  baseLabel,
37638
37871
  text: result.text,
37872
+ // Audit finding #9: timestamp captured at dispatch time, not
37873
+ // inside the reducer.
37874
+ generatedAt: Date.now(),
37639
37875
  });
37640
37876
  dispatch({
37641
37877
  type: 'setStatus',
@@ -37738,7 +37974,7 @@ function LogInkApp(deps) {
37738
37974
  if (editorOk) {
37739
37975
  try {
37740
37976
  const content = fs$1.readFileSync(file, 'utf8');
37741
- dispatch({ type: 'setChangelogText', text: content });
37977
+ dispatch({ type: 'setChangelogText', text: content, generatedAt: Date.now() });
37742
37978
  dispatch({ type: 'setStatus', value: 'Changelog updated from editor.' });
37743
37979
  }
37744
37980
  catch (error) {
@@ -38093,7 +38329,8 @@ function LogInkApp(deps) {
38093
38329
  // that could disagree with reality on partial-apply.
38094
38330
  const commitHashes = result.commitHashes || [];
38095
38331
  if (commitHashes.length > 0) {
38096
- dispatch({ type: 'markRecentCommits', hashes: commitHashes });
38332
+ // Audit finding #9: timestamp captured at dispatch time.
38333
+ dispatch({ type: 'markRecentCommits', hashes: commitHashes, markedAt: Date.now() });
38097
38334
  // DevSkim: ignore DS172411 — function literal, fixed delay,
38098
38335
  // no caller-supplied data flowing through.
38099
38336
  setTimeout(() => dispatch({ type: 'clearRecentCommits' }), 5000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-coco",
3
- "version": "0.54.0",
3
+ "version": "0.54.1",
4
4
  "description": "zero-effort git commits with coco.",
5
5
  "author": "gfargo <ghfargo@gmail.com>",
6
6
  "license": "MIT",