@remembrance-ai/opencode-plugin 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -134,6 +134,18 @@ const COMPLETION_OBLIGATION_GROWN_FIELDS = Object.freeze({
134
134
  }),
135
135
  });
136
136
  const PRIVATE_AUTHORIZATION_RETRY_INTERVAL_MS = 6 * 60 * 60 * 1000;
137
+ // A private draft whose session tree has had no user prompt for this long is
138
+ // offered to other sessions, and only as a user decision (owner decision,
139
+ // 0.2.3). The notice offering such drafts shows at most once per interval on
140
+ // a machine.
141
+ const PRIVATE_ORPHAN_AFTER_MS = 24 * 60 * 60 * 1000;
142
+ const PRIVATE_ORPHAN_NOTICE_INTERVAL_MS = 24 * 60 * 60 * 1000;
143
+ // A declined or host-denied draft returns in that notice after these waits,
144
+ // then not again until its lane is allowed, the user asks for it, or the
145
+ // client updates (owner decision, 0.2.3).
146
+ const PRIVATE_PARKED_NOTICE_DELAYS_MS = [1, 7, 30].map(
147
+ (days) => days * 24 * 60 * 60 * 1000,
148
+ );
137
149
  // Inherited cleanup for a debt record without content (a marker), which a
138
150
  // ledger read removes once it is this old. It never applies to a record
139
151
  // that keeps an unresolved action's payload: only completing, declining or
@@ -253,6 +265,18 @@ const PENDING_ACTION_DECLINE_REASONS = [
253
265
  ];
254
266
  const PENDING_ACTION_DECLINE_GUIDANCE =
255
267
  "If you cannot complete an action, call local decline_pending_action with the action_id under it and one reason (not_observed_by_this_agent, agent_exited, not_reusable, or duplicate) instead of guessing.";
268
+ // Accepted for a private line or notice only; feedback denials keep
269
+ // acknowledge_feedback_denial.
270
+ const PRIVATE_DECLINE_REASON = "host_policy_denied";
271
+ const PRIVATE_DECLINE_GUIDANCE =
272
+ "If the host denies a private line's exact action, decline that line with reason host_policy_denied; a declined private draft stays retained, returns later only as a user decision, and is never deleted automatically.";
273
+ // The private notice for drafts awaiting the user is not a ledger record;
274
+ // it has one fixed action_id, which a decline names. The held-safety notice
275
+ // names none: its delivery marks the drafts it showed, and it needs no reply.
276
+ const PRIVATE_ORPHAN_NOTICE_ACTION_ID = createHash("sha256")
277
+ .update("private_draft_notice:awaiting_user", "utf8")
278
+ .digest("hex")
279
+ .slice(0, 16);
256
280
  export const PRIVATE_LESSON_AUTHORIZATION_REQUEST_TEXT =
257
281
  "Allow Remembrance to submit this retained private lesson or skill candidate to your organization?";
258
282
  const PLUGIN_HEALTH_COMPONENTS = new Set([
@@ -2281,6 +2305,7 @@ export async function runPromptHook(prompt, options = {}) {
2281
2305
  acknowledgePrivateAuthorizationReminders(
2282
2306
  result?.context ?? "",
2283
2307
  options.env ?? process.env,
2308
+ options.sessionId ?? null,
2284
2309
  );
2285
2310
  }
2286
2311
  return result;
@@ -2312,8 +2337,14 @@ async function buildPromptHookResult(prompt, options = {}) {
2312
2337
  // Rendered for each prompt context in withContext below.
2313
2338
  const lifecycleRecovery =
2314
2339
  !options.sessionId && privateLearningLifecycleRecoveryInstruction(env);
2340
+ // `subagentSession`: the host runs this session for a subagent, or cannot
2341
+ // show it is a main session, and its ledger names no agent (OpenClaw's
2342
+ // sessions_spawn; on OpenCode, every session but one created with no
2343
+ // parent session, isRecordedMainSession).
2315
2344
  const recovery = options.sessionId
2316
- ? pendingCompletionRecovery(options.sessionId, env)
2345
+ ? pendingCompletionRecovery(options.sessionId, env, {
2346
+ notices: !options.subagentSession,
2347
+ })
2317
2348
  : lifecycleRecovery && (() => lifecycleRecovery);
2318
2349
  const recoveryAddition = recovery
2319
2350
  ? { id: "private_learning_recovery", kind: "completion" }
@@ -2672,6 +2703,7 @@ export function contributionReason(
2672
2703
  ? privateLessonCompletionGuidance({
2673
2704
  includeLifecycleRecovery: options.includeLifecycleRecovery !== false,
2674
2705
  env: options.env,
2706
+ lifecycleRecovery: options.lifecycleRecovery,
2675
2707
  })
2676
2708
  : null;
2677
2709
  if (directSelections.length > 0) {
@@ -2759,13 +2791,18 @@ function routineContributionVisibilityInstruction(privateCapture = false) {
2759
2791
  return `${visibility} ${FEEDBACK_DENIAL_GUIDANCE}`;
2760
2792
  }
2761
2793
 
2794
+ // `lifecycleRecovery`: a caller's session-scoped recovery text, used in place
2795
+ // of the machine-wide one.
2762
2796
  export function privateLessonCompletionGuidance({
2763
2797
  includeLifecycleRecovery = true,
2764
2798
  env = process.env,
2799
+ lifecycleRecovery,
2765
2800
  } = {}) {
2766
- const recovery = includeLifecycleRecovery
2767
- ? privateLearningLifecycleRecoveryInstruction(env)
2768
- : null;
2801
+ const recovery = !includeLifecycleRecovery
2802
+ ? null
2803
+ : lifecycleRecovery !== undefined
2804
+ ? lifecycleRecovery
2805
+ : privateLearningLifecycleRecoveryInstruction(env);
2769
2806
  return [
2770
2807
  "Call capture_learning once per new item: lesson for a compact generalized failure/correction, private_skill with complete instructions for a reusable procedure, or preference for a durable working preference. Follow its exact next_action or bounded agent_recovery instructions; reformulation updates the retained draft, not a new item.",
2771
2808
  "The router encrypts lesson and private-skill drafts locally before any network action. Do not substitute a legacy broad write, REST, another destination, or another visibility.",
@@ -2823,7 +2860,10 @@ export function taskClosureReason(signal = null, options = {}) {
2823
2860
  ? `Skills already selected for this session (${options.sessionSkills.join(", ")}) stay active; do not invoke or query them again.`
2824
2861
  : null,
2825
2862
  options.privateLessonAuthorized
2826
- ? privateLessonCompletionGuidance({ env: options.env })
2863
+ ? privateLessonCompletionGuidance({
2864
+ env: options.env,
2865
+ lifecycleRecovery: options.lifecycleRecovery,
2866
+ })
2827
2867
  : null,
2828
2868
  routineContributionVisibilityInstruction(options.privateLessonAuthorized),
2829
2869
  ]
@@ -2879,7 +2919,9 @@ export function hostPolicyAlertWasReported(input) {
2879
2919
  // eligibility with the last-prompted count. A completed use prompts for a
2880
2920
  // contribution; eligible work with no completed query prompts for full-context
2881
2921
  // recovery. `useCount`/`promptedCount` are injectable so tests never touch the
2882
- // filesystem.
2922
+ // filesystem. `subagentSession`: the host runs this session for a subagent,
2923
+ // or cannot show it is a main session (runPromptHook), so it gets no user
2924
+ // decisions.
2883
2925
  export function decideStop(input, options = {}) {
2884
2926
  const env = options.env ?? process.env;
2885
2927
  if (contributeDisabled(env.REMEMBRANCE_AUTO_CONTRIBUTE)) {
@@ -2920,9 +2962,11 @@ export function decideStop(input, options = {}) {
2920
2962
  const readObligations =
2921
2963
  options.readCompletionObligations ?? readCompletionObligations;
2922
2964
  const obligations = readObligations(sessionId, env);
2923
- const unpromptedObligations = obligations.filter(
2924
- (obligation) =>
2925
- !obligation.prompted_at && completionObligationIsDue(obligation, env),
2965
+ const drafts = readPrivateDrafts(env);
2966
+ const unpromptedObligations = duePendingActions(
2967
+ obligations.filter((obligation) => !obligation.prompted_at),
2968
+ env,
2969
+ drafts,
2926
2970
  );
2927
2971
  const overflow = readCompletionObligationOverflow(sessionId, env);
2928
2972
  const highValueSignal = detectHighValueLessonSignal(input);
@@ -2996,8 +3040,16 @@ export function decideStop(input, options = {}) {
2996
3040
  eligibilityCount > readClosed(sessionId, env) &&
2997
3041
  newDirectSelections.length === 0;
2998
3042
  const privateLessonAuthorized = organizationPrivateLessonAuthorized(env);
3043
+ // Scoped to this ledger's session tree: another session's drafts appear
3044
+ // only as a user decision, never as silent recovery.
2999
3045
  const privateLessonRecovery = privateLessonAuthorized
3000
- ? privateLessonLifecycleRecoveryInstruction(env)
3046
+ ? privateLearningSessionRecovery(
3047
+ sessionId,
3048
+ privateHintsOnMachine(sessionId, env),
3049
+ drafts,
3050
+ env,
3051
+ { notices: !options.subagentSession },
3052
+ )
3001
3053
  : null;
3002
3054
  if (
3003
3055
  newDirectSelections.length > 0 &&
@@ -3069,6 +3121,7 @@ export function decideStop(input, options = {}) {
3069
3121
  env,
3070
3122
  privateLessonAuthorized,
3071
3123
  sessionSkills,
3124
+ lifecycleRecovery: privateLessonRecovery,
3072
3125
  })
3073
3126
  : contributionReason(
3074
3127
  highValueSignal,
@@ -3080,6 +3133,7 @@ export function decideStop(input, options = {}) {
3080
3133
  env,
3081
3134
  privateLessonAuthorized,
3082
3135
  includeLifecycleRecovery: true,
3136
+ lifecycleRecovery: privateLessonRecovery,
3083
3137
  },
3084
3138
  ),
3085
3139
  ]
@@ -3131,6 +3185,9 @@ function highMatchClosureInstruction(match) {
3131
3185
  // record, since when, and of which kinds.
3132
3186
  // <hash>.user-prompts — the user prompts the agent received (host text and
3133
3187
  // a subagent's task are not counted).
3188
+ // <hash>.main-session — the host created this session with no parent.
3189
+ // <hash>.parent-session — the parent session id the host created this
3190
+ // child session with.
3134
3191
  // <hash>.rated.json — results the ledger paid post-use feedback for, each
3135
3192
  // with the user prompt (.user-prompts count) it
3136
3193
  // was paid in.
@@ -3411,13 +3468,29 @@ function privateAuthorizationMarker(draftId, env) {
3411
3468
  }
3412
3469
  }
3413
3470
 
3414
- function clearPrivateAuthorizationOpportunity(draftId, env) {
3415
- const path = privateAuthorizationMarkerPath(draftId, env);
3416
- if (!path) return;
3417
- try {
3418
- rmSync(path, { force: true });
3419
- } catch {
3420
- // Content-free scheduling state never controls draft acceptance/deletion.
3471
+ // A draft's content-free markers beside its hint: authorization backoff,
3472
+ // parking (a decline or host denial), and its one held-safety notice.
3473
+ const PRIVATE_DRAFT_MARKERS = ["authorization", "parked", "held-notice"];
3474
+
3475
+ function privateDraftMarkerPath(draftId, marker, env) {
3476
+ return /^(pld|psd)_[A-Za-z0-9_-]{8,96}$/.test(draftId ?? "")
3477
+ ? join(privateLearningLifecycleDirectory(env), `${draftId}.${marker}.json`)
3478
+ : null;
3479
+ }
3480
+
3481
+ function clearPrivateDraftMarkers(
3482
+ draftId,
3483
+ env,
3484
+ markers = PRIVATE_DRAFT_MARKERS,
3485
+ ) {
3486
+ for (const marker of markers) {
3487
+ const path = privateDraftMarkerPath(draftId, marker, env);
3488
+ if (!path) return;
3489
+ try {
3490
+ rmSync(path, { force: true });
3491
+ } catch {
3492
+ // Content-free scheduling state never controls draft acceptance/deletion.
3493
+ }
3421
3494
  }
3422
3495
  }
3423
3496
 
@@ -3481,18 +3554,19 @@ function clearKnownTerminalAuthorizationOpportunities(env) {
3481
3554
  assertSecureHookDirectory(lstatSync(directory));
3482
3555
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
3483
3556
  const match = entry.name.match(
3484
- /^((?:pld|psd)_[A-Za-z0-9_-]{8,96})\.authorization\.json$/,
3557
+ /^((?:pld|psd)_[A-Za-z0-9_-]{8,96})\.(authorization|parked|held-notice)\.json$/,
3485
3558
  );
3486
3559
  if (
3487
3560
  !match ||
3488
3561
  entry.isSymbolicLink() ||
3489
3562
  !entry.isFile() ||
3490
- !privateAuthorizationMarker(match[1], env) ||
3563
+ (match[2] === "authorization" &&
3564
+ !privateAuthorizationMarker(match[1], env)) ||
3491
3565
  !privateAuthorizationTerminalIsKnown(match[1], env)
3492
3566
  ) {
3493
3567
  continue;
3494
3568
  }
3495
- clearPrivateAuthorizationOpportunity(match[1], env);
3569
+ clearPrivateDraftMarkers(match[1], env, [match[2]]);
3496
3570
  }
3497
3571
  } catch {
3498
3572
  // Terminal cleanup is best-effort and never controls retained draft state.
@@ -3541,17 +3615,23 @@ function privateAuthorizationRetryIsDue(draftId, lastFailureAt, env) {
3541
3615
  );
3542
3616
  }
3543
3617
 
3618
+ // `ledgerId`: the ledger the delivered context was composed for. Where
3619
+ // another session of the same process can compose between a composition and
3620
+ // its delivery (OpenCode's turns, OpenClaw's prompts through runPromptHook),
3621
+ // the host names it, so that session's notice in the same words is not
3622
+ // acknowledged with this delivery's; without it, the newest composition is
3623
+ // (acknowledgePrivateDraftNotices).
3544
3624
  export function acknowledgePrivateAuthorizationReminders(
3545
3625
  context,
3546
3626
  env = process.env,
3627
+ ledgerId = null,
3547
3628
  ) {
3548
3629
  clearKnownTerminalAuthorizationOpportunities(env);
3549
3630
  retireKnownTerminalPrivateObligations(env);
3550
- if (
3551
- !organizationPrivateLessonAuthorized(env) ||
3552
- recentPrivateLessonHostDenial(env)
3553
- )
3554
- return;
3631
+ // Per-draft parking, not the lane-wide denial, now holds a denied draft's
3632
+ // line; an acknowledged retry still backs off.
3633
+ if (!organizationPrivateLessonAuthorized(env)) return;
3634
+ acknowledgePrivateDraftNotices(String(context ?? ""), env, ledgerId);
3555
3635
  const references = String(context ?? "").matchAll(
3556
3636
  /\bcall retry_private_(lesson|skill)_candidate with draft_id ((?:pld|psd)_[A-Za-z0-9_-]{8,96})(?![A-Za-z0-9_-])/g,
3557
3637
  );
@@ -3634,7 +3714,9 @@ function feedbackObligationDenied(obligation, env) {
3634
3714
  );
3635
3715
  }
3636
3716
 
3637
- function completionObligationIsDue(obligation, env) {
3717
+ // `drafts` (readPrivateDrafts), when the caller has read the outboxes once
3718
+ // for this prompt; without it a private hint reads its own draft's state.
3719
+ function completionObligationIsDue(obligation, env, drafts = null) {
3638
3720
  if (feedbackObligationDenied(obligation, env)) return false;
3639
3721
  if (!["private_lesson", "private_skill"].includes(obligation.kind))
3640
3722
  return true;
@@ -3642,12 +3724,37 @@ function completionObligationIsDue(obligation, env) {
3642
3724
  // an older content-free obligation after best-effort retirement.
3643
3725
  if (privateAuthorizationTerminalIsKnown(obligation.draft_id, env))
3644
3726
  return false;
3645
- if (obligation.delivery_blocked) return false;
3727
+ if (!organizationPrivateLessonAuthorized(env)) return false;
3728
+ // A parked draft returns only as a user decision, never as a silent line.
3646
3729
  if (
3647
- !organizationPrivateLessonAuthorized(env) ||
3648
- recentPrivateLessonHostDenial(env)
3730
+ drafts
3731
+ ? drafts.parked.has(obligation.draft_id)
3732
+ : readPrivateParkedMarker(obligation.draft_id, env)
3649
3733
  )
3650
3734
  return false;
3735
+ // A lesson's own envelope says what is due now; the hint's recorded
3736
+ // fields decide only when its outbox cannot answer. Only the hint knows
3737
+ // which credential an authorization refusal named: a changed one is due.
3738
+ if (obligation.kind === "private_lesson") {
3739
+ const known = privateLessonHintAction(
3740
+ obligation.draft_id,
3741
+ env,
3742
+ drafts?.lessons,
3743
+ );
3744
+ if (
3745
+ known?.action?.count === "awaiting_authorization" &&
3746
+ !known.action.due &&
3747
+ obligation.failed_credential_digest &&
3748
+ !privateAuthorizationMarker(obligation.draft_id, env)
3749
+ ) {
3750
+ return (
3751
+ obligation.failed_credential_digest !==
3752
+ privateObligationCredentialDigest(env)
3753
+ );
3754
+ }
3755
+ if (known) return Boolean(known.action?.due);
3756
+ }
3757
+ if (obligation.delivery_blocked) return false;
3651
3758
  if (obligation.next_retry_at && !retryIsDue(obligation.next_retry_at))
3652
3759
  return false;
3653
3760
  if (!obligation.failed_credential_digest) return true;
@@ -3808,6 +3915,7 @@ function normalizeCompletionObligation(value) {
3808
3915
  ...(value.subagent_identity === "unavailable"
3809
3916
  ? { subagent_identity: "unavailable" }
3810
3917
  : {}),
3918
+ ...privateHintOwnerFields(value),
3811
3919
  ...normalizedAliasQueryIds(value),
3812
3920
  // The subagent that left this debt unpaid when it exited.
3813
3921
  transferred_from: normalizeNativeSessionId(value.transferred_from?.agent_id)
@@ -3850,6 +3958,82 @@ function normalizeCompletionObligation(value) {
3850
3958
  };
3851
3959
  }
3852
3960
 
3961
+ // Who a private hint belongs to: the session tree that captured or last
3962
+ // acted on its draft (owner_session, the main session's ledger), and the
3963
+ // ledger and agent that did (captured_by_*). A hint without them (written
3964
+ // by an earlier client) belongs to no session. All are content-free.
3965
+ function privateHintOwnerFields(value) {
3966
+ if (
3967
+ !["private_lesson", "private_skill"].includes(value.kind) ||
3968
+ !/^[a-f0-9]{16}$/.test(value.owner_session ?? "") ||
3969
+ !/^[a-f0-9]{16}$/.test(value.captured_by_ledger ?? "")
3970
+ ) {
3971
+ return {};
3972
+ }
3973
+ const agent = normalizeNativeSessionId(value.captured_by_agent);
3974
+ return {
3975
+ owner_session: value.owner_session,
3976
+ captured_by_ledger: value.captured_by_ledger,
3977
+ ...(agent ? { captured_by_agent: agent } : {}),
3978
+ };
3979
+ }
3980
+
3981
+ // The owner fields for a hint `sessionId` (a ledger id, agentLedgerIdFor's
3982
+ // form) records.
3983
+ function privateHintOwner(sessionId) {
3984
+ const ledger = String(sessionId ?? "unknown");
3985
+ const split = ledger.indexOf(":agent:");
3986
+ const agent =
3987
+ split >= 0 ? normalizeNativeSessionId(ledger.slice(split + 7)) : null;
3988
+ return {
3989
+ owner_session: sessionHash(split >= 0 ? ledger.slice(0, split) : ledger),
3990
+ captured_by_ledger: sessionHash(ledger),
3991
+ ...(agent ? { captured_by_agent: agent } : {}),
3992
+ };
3993
+ }
3994
+
3995
+ // A main thread holds the private hints of its whole session tree, a
3996
+ // subagent only those it captured.
3997
+ function privateHintOwnedBy(hint, sessionId) {
3998
+ if (sessionId !== null && typeof sessionId === "object") return false;
3999
+ const ledger = String(sessionId ?? "unknown");
4000
+ return ledger.includes(":agent:")
4001
+ ? hint.captured_by_ledger === sessionHash(ledger)
4002
+ : hint.owner_session === sessionHash(ledger);
4003
+ }
4004
+
4005
+ function isPrivateHint(obligation) {
4006
+ return ["private_lesson", "private_skill"].includes(obligation?.kind);
4007
+ }
4008
+
4009
+ // Rewrites a private hint where it is held with `to` as its owner; nothing
4010
+ // else of it changes, and its draft is never touched. When the session
4011
+ // whose tree `to` is in has handed over itself (OpenClaw: a nested subagent
4012
+ // that ends after the one that spawned it), the hint goes on, as that
4013
+ // session's debts went, to the session its forwarding markers lead to
4014
+ // (resolveCompletionRecipient), which is the one that reads it.
4015
+ function adoptPrivateHint(hint, to, env) {
4016
+ if (typeof to !== "string" || !lifecycleDirectoryUsable(env)) return false;
4017
+ const owner = privateHintOwner(to);
4018
+ const { recipient } = resolveCompletionRecipient(
4019
+ { ledger: owner.owner_session },
4020
+ to,
4021
+ env,
4022
+ );
4023
+ const adopted = normalizeCompletionObligation({
4024
+ ...hint,
4025
+ ...owner,
4026
+ owner_session: sessionHash(recipient),
4027
+ });
4028
+ return Boolean(
4029
+ adopted &&
4030
+ writePrivateUsageMarker(
4031
+ completionObligationPath(to, adopted.id, env),
4032
+ `${JSON.stringify(adopted)}\n`,
4033
+ ),
4034
+ );
4035
+ }
4036
+
3853
4037
  function normalizedAliasQueryIds(value) {
3854
4038
  if (
3855
4039
  value.kind !== "post_use_feedback" ||
@@ -3885,7 +4069,9 @@ function transferTranscriptPath(value) {
3885
4069
 
3886
4070
  // `pointers: false` reads only the ledger's own records, for a decision that
3887
4071
  // needs nothing else (is it held, is it full): no pointer entry is examined,
3888
- // and such a read never counts the ledger as drained.
4072
+ // and such a read never counts the ledger as drained. `privateHints: "all"`
4073
+ // shows every private hint on this machine, not only the reader's
4074
+ // (privateHintOwnedBy): for a removal, retirement, or the orphan scan.
3889
4075
  export function readCompletionObligations(
3890
4076
  sessionId,
3891
4077
  env = process.env,
@@ -3926,6 +4112,7 @@ function readCompletionLedger(
3926
4112
  associations = "expand",
3927
4113
  settle = false,
3928
4114
  ask = true,
4115
+ privateHints = "own",
3929
4116
  } = {},
3930
4117
  ) {
3931
4118
  const obligations = new Map();
@@ -4073,6 +4260,7 @@ function readCompletionLedger(
4073
4260
  // Non-expiring private hints must never occupy the session feedback budget.
4074
4261
  // Records another ledger keeps for this one come after its own; they never
4075
4262
  // count against its own bound.
4263
+ const hints = ordered.filter((item) => privateKinds.includes(item.kind));
4076
4264
  return {
4077
4265
  complete,
4078
4266
  obligations: [
@@ -4080,9 +4268,11 @@ function readCompletionLedger(
4080
4268
  .filter((item) => !privateKinds.includes(item.kind))
4081
4269
  .slice(0, COMPLETION_OBLIGATION_LIMIT),
4082
4270
  ...pointed,
4083
- ...ordered
4084
- .filter((item) => privateKinds.includes(item.kind))
4085
- .slice(0, COMPLETION_OBLIGATION_LIMIT),
4271
+ ...(privateHints === "all"
4272
+ ? hints
4273
+ : hints
4274
+ .filter((item) => privateHintOwnedBy(item, sessionId))
4275
+ .slice(0, COMPLETION_OBLIGATION_LIMIT)),
4086
4276
  ],
4087
4277
  };
4088
4278
  }
@@ -4686,6 +4876,28 @@ function completionLedgerIsFull(existing, obligation) {
4686
4876
  );
4687
4877
  }
4688
4878
 
4879
+ // A full machine-wide bound of private hints makes room for the writer's by
4880
+ // dropping the oldest hint whose draft is already the user's to decide
4881
+ // (privateDraftsAwaitingUser): a parked draft's, or one with no owner or an
4882
+ // owner outside the writer's tree that is idle (privateHintIdle). That
4883
+ // draft is offered as before; only its pointer goes. When every hint
4884
+ // belongs to an active session, none goes, and the new draft, with no hint,
4885
+ // reaches the user as one of those decisions.
4886
+ function evictUserGatedPrivateHint(sessionId, existing, env) {
4887
+ const writer = privateHintOwner(sessionId).owner_session;
4888
+ const gated = existing.find(
4889
+ (hint) =>
4890
+ isPrivateHint(hint) &&
4891
+ !hint.ledger &&
4892
+ (readPrivateParkedMarker(hint.draft_id, env) ||
4893
+ (hint.owner_session !== writer &&
4894
+ (!hint.owner_session ||
4895
+ privateHintIdle(hint, (ledger) => privateSessionIdle(ledger, env))))),
4896
+ );
4897
+ if (gated) dropPrivateHint(gated.draft_id, env);
4898
+ return Boolean(gated);
4899
+ }
4900
+
4689
4901
  // Debts a full ledger could not record are counted, never dropped silently:
4690
4902
  // every demand for that ledger reports them until it has drained.
4691
4903
  function readCompletionObligationOverflow(sessionId, env) {
@@ -4758,12 +4970,17 @@ function writeCompletionObligation(sessionId, obligation, env, { kept = false }
4758
4970
  const ledger = /^[a-f0-9]{16}$/.test(obligation?.ledger ?? "")
4759
4971
  ? obligation.ledger
4760
4972
  : own;
4761
- if (
4973
+ // Private hints are bounded per machine, whoever owns them.
4974
+ const existing =
4762
4975
  ledger === own &&
4763
- completionLedgerIsFull(
4764
- readCompletionObligations(sessionId, env, { pointers: false }),
4765
- normalized,
4766
- )
4976
+ readCompletionObligations(sessionId, env, {
4977
+ pointers: false,
4978
+ privateHints: "all",
4979
+ });
4980
+ if (
4981
+ existing &&
4982
+ completionLedgerIsFull(existing, normalized) &&
4983
+ !(isPrivateHint(normalized) && evictUserGatedPrivateHint(sessionId, existing, env))
4767
4984
  ) {
4768
4985
  // A private hint only points into the private-learning outbox, which
4769
4986
  // stays authoritative; any other debt not kept elsewhere is counted.
@@ -4864,15 +5081,21 @@ function writeRetainedRecord(ledger, name, serialized, normalized, env) {
4864
5081
  return true;
4865
5082
  }
4866
5083
 
5084
+ // A private hint is (re)stamped with the recording ledger as its owner: the
5085
+ // session tree that captured its draft, or acted on it last (an observed
5086
+ // submit or retry adopts another session's draft).
4867
5087
  function addCompletionObligation(sessionId, obligation, env = process.env) {
4868
- const existing = heldCompletionObligations(sessionId, env).find(
4869
- (item) => item.id === obligation.id,
4870
- );
5088
+ const existing = readCompletionObligations(sessionId, env, {
5089
+ ask: false,
5090
+ privateHints: "all",
5091
+ }).find((item) => item.id === obligation.id);
5092
+ const owner = isPrivateHint(obligation) ? privateHintOwner(sessionId) : {};
4871
5093
  return writeCompletionObligation(
4872
5094
  sessionId,
4873
5095
  {
4874
5096
  version: COMPLETION_OBLIGATION_VERSION,
4875
5097
  ...obligation,
5098
+ ...owner,
4876
5099
  agent_keyed: true,
4877
5100
  ...(subagentIdentityUnavailable
4878
5101
  ? { subagent_identity: "unavailable" }
@@ -4882,7 +5105,8 @@ function addCompletionObligation(sessionId, obligation, env = process.env) {
4882
5105
  existing?.action_tool === (obligation.action_tool ?? null) &&
4883
5106
  existing?.next_retry_at === (obligation.next_retry_at ?? null) &&
4884
5107
  existing?.failed_credential_digest ===
4885
- (obligation.failed_credential_digest ?? null)
5108
+ (obligation.failed_credential_digest ?? null) &&
5109
+ existing?.captured_by_ledger === owner.captured_by_ledger
4886
5110
  ? (existing?.prompted_at ?? null)
4887
5111
  : null,
4888
5112
  },
@@ -4905,7 +5129,8 @@ function addCompletionObligation(sessionId, obligation, env = process.env) {
4905
5129
  // points to. One stop hands over one page; the parent's reads reach the rest
4906
5130
  // through one association (handOverLedger). A kept debt is never counted.
4907
5131
  // Private-learning hints are held per machine, not per agent, and stay where
4908
- // they are.
5132
+ // they are; the subagent's session tree, its parent's, already owns them
5133
+ // (privateHintOwner).
4909
5134
  export function transferSubagentObligations(
4910
5135
  input,
4911
5136
  env = process.env,
@@ -5014,7 +5239,11 @@ function handOverLedger(
5014
5239
  let moved = 0;
5015
5240
  let handed = complete;
5016
5241
  // Records of `from`'s own that this page leaves in its homes: kept records
5017
- // `to` now points to. (Private hints are held per machine, not there.)
5242
+ // `to` now points to. (Private hints are held per machine, not there, and
5243
+ // stay where they are: a subagent's already belong to `to`'s session tree,
5244
+ // an OpenClaw subagent session's went to the session that reads `to`'s
5245
+ // tree when its fold began (foldTurnLedgerIntoSession), and a Codex turn
5246
+ // records none.)
5018
5247
  let left = 0;
5019
5248
  for (const obligation of obligations) {
5020
5249
  if (obligation.association) {
@@ -5041,7 +5270,7 @@ function handOverLedger(
5041
5270
  }
5042
5271
  continue;
5043
5272
  }
5044
- if (["private_lesson", "private_skill"].includes(obligation.kind)) continue;
5273
+ if (isPrivateHint(obligation)) continue;
5045
5274
  const result = handOverCompletionObligation(
5046
5275
  from,
5047
5276
  to,
@@ -5465,12 +5694,22 @@ function removeCompletionObligationRecord(sessionId, obligation, env, durable) {
5465
5694
  return ledger;
5466
5695
  }
5467
5696
 
5468
- function removeCompletionObligations(sessionId, predicate, env) {
5697
+ // A private hint any session holds can be removed (its draft was accepted,
5698
+ // deleted or is no longer actionable); a decline passes `privateHints:
5699
+ // "own"`, closing only what the caller was shown, and `keepAuthorization`,
5700
+ // since its draft is only parked.
5701
+ function removeCompletionObligations(
5702
+ sessionId,
5703
+ predicate,
5704
+ env,
5705
+ { privateHints = "all", keepAuthorization = false } = {},
5706
+ ) {
5469
5707
  const removed = [];
5470
5708
  const durable = retainedRootsUsable(env);
5471
5709
  const emptied = new Set();
5472
5710
  for (const obligation of readCompletionObligations(sessionId, env, {
5473
5711
  settle: true,
5712
+ privateHints,
5474
5713
  })) {
5475
5714
  if (!predicate(obligation)) continue;
5476
5715
  try {
@@ -5496,7 +5735,9 @@ function removeCompletionObligations(sessionId, predicate, env) {
5496
5735
  join(legacyPrivateCompletionObligationDir(env), `${hash}.json`),
5497
5736
  { force: true },
5498
5737
  );
5499
- const marker = privateAuthorizationMarkerPath(obligation.draft_id, env);
5738
+ const marker =
5739
+ !keepAuthorization &&
5740
+ privateAuthorizationMarkerPath(obligation.draft_id, env);
5500
5741
  if (marker) rmSync(marker, { force: true });
5501
5742
  }
5502
5743
  removed.push(obligation);
@@ -5726,7 +5967,14 @@ export function recordPrivateLessonObligation(
5726
5967
  const body = responseBodyFromToolResponse(response);
5727
5968
  const draftId = completionIdentifier(body?.draft_id);
5728
5969
  if (!/^pld_[A-Za-z0-9_-]{8,96}$/.test(draftId)) return null;
5729
- if (["submitted", "superseded_redactor"].includes(body?.state)) return null;
5970
+ // The draft's own envelope says which exact action is due; the response's
5971
+ // exact next_action stands in only while the envelope cannot be read.
5972
+ // Nothing an agent can do (null): no hint, and the caller retires one.
5973
+ const envelope = readPrivateLessonEnvelope(draftId, env);
5974
+ const actionTool = envelope
5975
+ ? (privateLessonDraftAction(envelope, env)?.tool ?? null)
5976
+ : privateLessonSubmissionAction(body, draftId);
5977
+ if (!actionTool) return null;
5730
5978
  if (
5731
5979
  body?.state === "awaiting_authorization" &&
5732
5980
  body?.status === "not_submitted"
@@ -5740,21 +5988,8 @@ export function recordPrivateLessonObligation(
5740
5988
  kind: "private_lesson",
5741
5989
  engagement_count: Math.max(1, engagementCount),
5742
5990
  draft_id: draftId,
5743
- action_tool: [
5744
- "retry_scheduled",
5745
- "awaiting_authorization",
5746
- "awaiting_compatible_profile",
5747
- ].includes(body?.state)
5748
- ? "retry_private_lesson_candidate"
5749
- : "submit_private_lesson_candidate",
5991
+ action_tool: actionTool,
5750
5992
  next_retry_at: body?.next_retry_at ?? null,
5751
- delivery_blocked:
5752
- body?.status === "not_submitted" &&
5753
- ![
5754
- "retry_scheduled",
5755
- "awaiting_authorization",
5756
- "awaiting_compatible_profile",
5757
- ].includes(body?.state),
5758
5993
  failed_credential_digest:
5759
5994
  body?.state === "awaiting_authorization" &&
5760
5995
  body?.status === "not_submitted"
@@ -5812,29 +6047,41 @@ export function recordPrivateSkillObligation(
5812
6047
  );
5813
6048
  }
5814
6049
 
5815
- function privateSkillSubmissionAction(value, draftId) {
6050
+ // A response's exact next_action for one draft: the named lane's submit or
6051
+ // retry tool with exactly { draft_id }.
6052
+ function exactPrivateDraftAction(value, draftId, lane) {
5816
6053
  const action = value?.next_action;
6054
+ return action &&
6055
+ typeof action === "object" &&
6056
+ !Array.isArray(action) &&
6057
+ [
6058
+ `submit_private_${lane}_candidate`,
6059
+ `retry_private_${lane}_candidate`,
6060
+ ].includes(action.tool) &&
6061
+ action.arguments &&
6062
+ typeof action.arguments === "object" &&
6063
+ !Array.isArray(action.arguments) &&
6064
+ Object.keys(action.arguments).join(",") === "draft_id" &&
6065
+ action.arguments.draft_id === draftId
6066
+ ? action.tool
6067
+ : null;
6068
+ }
6069
+
6070
+ function privateLessonSubmissionAction(value, draftId) {
6071
+ return exactPrivateDraftAction(value, draftId, "lesson");
6072
+ }
6073
+
6074
+ function privateSkillSubmissionAction(value, draftId) {
6075
+ const tool = exactPrivateDraftAction(value, draftId, "skill");
5817
6076
  if (
5818
- !action ||
5819
- typeof action !== "object" ||
5820
- Array.isArray(action) ||
5821
- ![
5822
- "submit_private_skill_candidate",
5823
- "retry_private_skill_candidate",
5824
- ].includes(action.tool) ||
5825
- !action.arguments ||
5826
- typeof action.arguments !== "object" ||
5827
- Array.isArray(action.arguments) ||
5828
- Object.keys(action.arguments).join(",") !== "draft_id" ||
5829
- action.arguments.draft_id !== draftId ||
5830
- (action.tool === "submit_private_skill_candidate" &&
5831
- value?.state !== "ready") ||
5832
- (action.tool === "retry_private_skill_candidate" &&
6077
+ !tool ||
6078
+ (tool === "submit_private_skill_candidate" && value?.state !== "ready") ||
6079
+ (tool === "retry_private_skill_candidate" &&
5833
6080
  !["retry_scheduled", "awaiting_authorization"].includes(value?.state))
5834
6081
  ) {
5835
6082
  return null;
5836
6083
  }
5837
- return action.tool;
6084
+ return tool;
5838
6085
  }
5839
6086
 
5840
6087
  function recordCaptureLearningObligation(
@@ -6187,10 +6434,17 @@ function retainedPayloadOpener(env) {
6187
6434
  };
6188
6435
  }
6189
6436
 
6437
+ // A private hint counts as pending only while its action is due.
6438
+ function completionObligationPending(obligation, env) {
6439
+ return isPrivateHint(obligation)
6440
+ ? completionObligationIsDue(obligation, env)
6441
+ : !feedbackObligationDenied(obligation, env);
6442
+ }
6443
+
6190
6444
  function markEngagementHandledWhenClear(sessionId, env) {
6191
6445
  const count = currentEngagementCount(sessionId, env);
6192
6446
  const hasPending = heldCompletionObligations(sessionId, env).some(
6193
- (obligation) => !feedbackObligationDenied(obligation, env),
6447
+ (obligation) => completionObligationPending(obligation, env),
6194
6448
  );
6195
6449
  if (!hasPending && count > 0) {
6196
6450
  writePromptedCount(sessionId, count, env);
@@ -6304,7 +6558,7 @@ export function observeSuccessfulCompletionTool(
6304
6558
  (body?.version === "private-lesson-outbox-deletion-v1" &&
6305
6559
  body?.reason === "explicit_confirmed_deletion"))
6306
6560
  ) {
6307
- clearPrivateAuthorizationOpportunity(input.draft_id, env);
6561
+ clearPrivateDraftMarkers(input.draft_id, env);
6308
6562
  closed = removeCompletionObligations(
6309
6563
  sessionId,
6310
6564
  (obligation) =>
@@ -6347,17 +6601,34 @@ export function observeSuccessfulCompletionTool(
6347
6601
  if (followup) opened.push(followup);
6348
6602
  } else if (tool === "decline_pending_action") {
6349
6603
  // Only the observed call closes an action, in the caller's own ledger;
6350
- // nothing in a message can.
6604
+ // nothing in a message can. A declined private line parks its draft
6605
+ // (keeping its authorization backoff), a declined notice for drafts
6606
+ // awaiting the user the drafts it offered; host_policy_denied is for
6607
+ // private lines and notices only. The held-safety notice names no
6608
+ // action to decline (an earlier client's did, and its decline changes
6609
+ // nothing): its delivery marked the drafts it showed.
6351
6610
  const actionId = String(input.action_id ?? "");
6352
- closed = PENDING_ACTION_DECLINE_REASONS.includes(input.reason)
6353
- ? removeCompletionObligations(
6354
- sessionId,
6355
- (obligation) =>
6356
- completionActionDeclinable(obligation) &&
6357
- completionActionId(obligation) === actionId,
6358
- env,
6359
- )
6360
- : [];
6611
+ const reason = String(input.reason ?? "");
6612
+ const privateOnly = reason === PRIVATE_DECLINE_REASON;
6613
+ if (PENDING_ACTION_DECLINE_REASONS.includes(reason) || privateOnly) {
6614
+ closed = removeCompletionObligations(
6615
+ sessionId,
6616
+ (obligation) =>
6617
+ (!privateOnly || isPrivateHint(obligation)) &&
6618
+ completionActionId(obligation) === actionId,
6619
+ env,
6620
+ { privateHints: "own", keepAuthorization: true },
6621
+ );
6622
+ for (const hint of closed.filter(isPrivateHint)) {
6623
+ parkPrivateDraft(hint.draft_id, reason, env);
6624
+ }
6625
+ if (
6626
+ actionId === PRIVATE_ORPHAN_NOTICE_ACTION_ID &&
6627
+ organizationPrivateLessonAuthorized(env)
6628
+ ) {
6629
+ parkPrivateDraftsAwaitingUser(sessionId, reason, env);
6630
+ }
6631
+ }
6361
6632
  } else if (tool === "submit_remembrance") {
6362
6633
  const digest = completionPayloadDigest(input);
6363
6634
  const queryId = completionIdentifier(input?.interaction?.query_id);
@@ -6396,6 +6667,10 @@ export function observeSuccessfulCompletionTool(
6396
6667
  ) {
6397
6668
  const body = responseBodyFromToolResponse(response);
6398
6669
  const draftId = completionIdentifier(input.draft_id ?? body?.draft_id);
6670
+ // The host let this exact call through, whatever the server answered:
6671
+ // the lane is allowed again, and this explicit request unparks the draft.
6672
+ allowPrivateLane("pld_", env);
6673
+ clearPrivateDraftMarkers(draftId, env, ["parked"]);
6399
6674
  if (body?.retained_locally === true) {
6400
6675
  const retained = recordPrivateLessonObligation(
6401
6676
  sessionId,
@@ -6430,13 +6705,15 @@ export function observeSuccessfulCompletionTool(
6430
6705
  obligation.draft_id === draftId,
6431
6706
  env,
6432
6707
  );
6433
- clearPrivateAuthorizationOpportunity(draftId, env);
6708
+ clearPrivateDraftMarkers(draftId, env);
6434
6709
  } else if (
6435
6710
  tool === "submit_private_skill_candidate" ||
6436
6711
  tool === "retry_private_skill_candidate"
6437
6712
  ) {
6438
6713
  const body = responseBodyFromToolResponse(response);
6439
6714
  const draftId = completionIdentifier(input.draft_id ?? body?.draft_id);
6715
+ allowPrivateLane("psd_", env);
6716
+ clearPrivateDraftMarkers(draftId, env, ["parked"]);
6440
6717
  if (body?.retained_locally === true) {
6441
6718
  const retained = recordPrivateSkillObligation(
6442
6719
  sessionId,
@@ -6469,7 +6746,7 @@ export function observeSuccessfulCompletionTool(
6469
6746
  obligation.draft_id === draftId,
6470
6747
  env,
6471
6748
  );
6472
- clearPrivateAuthorizationOpportunity(draftId, env);
6749
+ clearPrivateDraftMarkers(draftId, env);
6473
6750
  }
6474
6751
 
6475
6752
  const handled = COMPLETION_CONTRIBUTION_TOOLS.has(tool)
@@ -6547,13 +6824,8 @@ function preferenceOfferPointer(obligation, directSelections) {
6547
6824
  return `; offered ${label}${tail}`;
6548
6825
  }
6549
6826
 
6550
- // A reminder lists each declinable action with this id, which
6551
- // decline_pending_action echoes back. Private-learning hints point into the
6552
- // private-learning outbox and are handled there, not declined.
6553
- function completionActionDeclinable(obligation) {
6554
- return !["private_lesson", "private_skill"].includes(obligation.kind);
6555
- }
6556
-
6827
+ // A reminder lists each action with this id, which decline_pending_action
6828
+ // echoes back. A declined private line parks its draft (parkPrivateDraft).
6557
6829
  function completionActionId(obligation) {
6558
6830
  return createHash("sha256")
6559
6831
  .update(obligation.id, "utf8")
@@ -6593,7 +6865,8 @@ function completionObligationInstruction(
6593
6865
  const shown = obligations.slice(0, 12);
6594
6866
  // The first line with each tag carries the tag's explanation, so the
6595
6867
  // context allocator keeps or drops the two together. Private-learning
6596
- // lines, which name no provenance, always come after the others.
6868
+ // lines always come after the others; one a subagent of this session
6869
+ // captured says which.
6597
6870
  const explained = new Set(
6598
6871
  [...PROVENANCE_TAG_EXPLANATIONS.keys()].map((tag) =>
6599
6872
  shown.find((obligation) => provenanceLabel(obligation) === tag),
@@ -6633,17 +6906,22 @@ function completionObligationInstruction(
6633
6906
  ? `- Reusable evidence${ids}${from}: ${unretained}; call submit_remembrance with the payload submit_feedback returned only if you still hold it verbatim.`
6634
6907
  : `- Reusable evidence${ids}${from}: submit the exact next_step.submit_remembrance_payload returned by the preceding feedback call.`;
6635
6908
  }
6909
+ const by =
6910
+ obligation.captured_by_agent &&
6911
+ obligation.captured_by_ledger !== retained.ledger
6912
+ ? ` (captured by subagent ${obligation.captured_by_agent})`
6913
+ : "";
6636
6914
  if (obligation.kind === "private_lesson") {
6637
- return `- Private lesson: call ${obligation.action_tool ?? "submit_private_lesson_candidate"} with draft_id ${obligation.draft_id}.`;
6915
+ return `- Private lesson${by}${from}: call ${obligation.action_tool ?? "submit_private_lesson_candidate"} with draft_id ${obligation.draft_id}.`;
6638
6916
  }
6639
6917
  if (obligation.kind === "private_skill") {
6640
6918
  if (obligation.action_tool === "inspect_private_learning_outbox") {
6641
- return `- Recoverable private skill: call inspect_private_learning_outbox with private_skill_draft_id ${obligation.draft_id}, then follow its bounded agent reformulation instructions. Do not submit the held original.`;
6919
+ return `- Recoverable private skill${by}${from}: call inspect_private_learning_outbox with private_skill_draft_id ${obligation.draft_id}, then follow its bounded agent reformulation instructions. Do not submit the held original.`;
6642
6920
  }
6643
6921
  if (obligation.action_tool === "retry_private_skill_candidate") {
6644
- return `- Retry private skill: call retry_private_skill_candidate with draft_id ${obligation.draft_id}.`;
6922
+ return `- Retry private skill${by}${from}: call retry_private_skill_candidate with draft_id ${obligation.draft_id}.`;
6645
6923
  }
6646
- return `- Complete private skill: call submit_private_skill_candidate with draft_id ${obligation.draft_id}.`;
6924
+ return `- Complete private skill${by}${from}: call submit_private_skill_candidate with draft_id ${obligation.draft_id}.`;
6647
6925
  }
6648
6926
  if (obligation.local_resupply_envelope) {
6649
6927
  return `- Durable preference${from}: arguments kept on this machine (${retainedPayloadBytes(obligation)} bytes, ${obligation.payload_digest}); call local get_pending_action_payload with action_id ${completionActionId(obligation)} and ledger ${obligation.ledger ?? retained.ledger}, then record_preference with exactly the arguments it returns.`;
@@ -6658,9 +6936,7 @@ function completionObligationInstruction(
6658
6936
  : `- Durable preference${from}: call record_preference with the exact next_action returned by capture_learning.`;
6659
6937
  });
6660
6938
  shown.forEach((obligation, index) => {
6661
- if (completionActionDeclinable(obligation)) {
6662
- lines[index] += `\n action_id ${completionActionId(obligation)}`;
6663
- }
6939
+ lines[index] += `\n action_id ${completionActionId(obligation)}`;
6664
6940
  });
6665
6941
  if (obligations.length > lines.length) {
6666
6942
  lines.push(
@@ -6696,9 +6972,8 @@ function completionObligationInstruction(
6696
6972
  return [
6697
6973
  "Before finishing, silently complete only these pending Remembrance actions:",
6698
6974
  ...lines,
6699
- shown.some(completionActionDeclinable)
6700
- ? PENDING_ACTION_DECLINE_GUIDANCE
6701
- : null,
6975
+ PENDING_ACTION_DECLINE_GUIDANCE,
6976
+ shown.some(isPrivateHint) ? PRIVATE_DECLINE_GUIDANCE : null,
6702
6977
  FEEDBACK_DENIAL_GUIDANCE,
6703
6978
  "Do not mention routine hook, tool, or receipt output. Surface only a failure or required user action.",
6704
6979
  ]
@@ -6762,11 +7037,44 @@ export function pendingCompletionRecoveryInstruction(
6762
7037
  // of more bytes than the cap is not opened, since its line cannot fit. A
6763
7038
  // durable preference's arguments are never opened here: only its handle is
6764
7039
  // shown.
6765
- export function pendingCompletionRecovery(sessionId, env = process.env) {
6766
- const obligations = readCompletionObligations(sessionId, env).filter(
6767
- (obligation) => completionObligationIsDue(obligation, env),
7040
+ // The outboxes are read once for the whole prompt (readPrivateDrafts); the
7041
+ // ledger once, with every private hint on the machine, of which this session
7042
+ // tree's own become lines and the rest only feed the user decisions, which
7043
+ // a session the host runs for a subagent does not get (`notices: false`).
7044
+ export function pendingCompletionRecovery(
7045
+ sessionId,
7046
+ env = process.env,
7047
+ { notices = true } = {},
7048
+ ) {
7049
+ const drafts = readPrivateDrafts(env);
7050
+ const held = readCompletionObligations(sessionId, env, {
7051
+ privateHints: "all",
7052
+ });
7053
+ const retired = retireUnactionablePrivateLessonHints(
7054
+ held.filter(isPrivateHint),
7055
+ drafts,
7056
+ env,
7057
+ );
7058
+ const hints = held.filter(
7059
+ (obligation) => isPrivateHint(obligation) && !retired.has(obligation.id),
7060
+ );
7061
+ const obligations = duePendingActions(
7062
+ [
7063
+ ...held.filter((obligation) => !isPrivateHint(obligation)),
7064
+ ...hints
7065
+ .filter((hint) => privateHintOwnedBy(hint, sessionId))
7066
+ .slice(0, COMPLETION_OBLIGATION_LIMIT),
7067
+ ],
7068
+ env,
7069
+ drafts,
7070
+ );
7071
+ const lifecycle = privateLearningSessionRecovery(
7072
+ sessionId,
7073
+ hints,
7074
+ drafts,
7075
+ env,
7076
+ { notices },
6768
7077
  );
6769
- const lifecycle = privateLearningLifecycleRecoveryInstruction(env);
6770
7078
  if (obligations.length === 0 && !lifecycle) return null;
6771
7079
  const directSelections = readDirectSelectionSurfaces(sessionId, env);
6772
7080
  const overflow = readCompletionObligationOverflow(sessionId, env);
@@ -6868,8 +7176,8 @@ export function markCurrentEngagementHandled(sessionId, env = process.env) {
6868
7176
  );
6869
7177
  if (
6870
7178
  count > 0 &&
6871
- !heldCompletionObligations(sessionId, env).some(
6872
- (obligation) => !feedbackObligationDenied(obligation, env),
7179
+ !heldCompletionObligations(sessionId, env).some((obligation) =>
7180
+ completionObligationPending(obligation, env),
6873
7181
  )
6874
7182
  ) {
6875
7183
  writePromptedCount(sessionId, count, env);
@@ -6909,6 +7217,79 @@ export function readUserPromptCount(sessionId, env = process.env) {
6909
7217
  return readCountFile(userPromptCountPath(sessionId, env));
6910
7218
  }
6911
7219
 
7220
+ // A session the host showed to be a main session, not one it runs for a
7221
+ // subagent: OpenCode's session.created names no parent session. Kept,
7222
+ // content-free, with the session's other usage markers, so the host's next
7223
+ // process still knows a session it resumes. On such a host only these get
7224
+ // the user decisions about retained drafts (`subagentSession`).
7225
+ function mainSessionPath(sessionId, env) {
7226
+ return join(usageDir(env), `${sessionHash(sessionId)}.main-session`);
7227
+ }
7228
+
7229
+ export function recordMainSession(sessionId, env = process.env) {
7230
+ return writePrivateUsageMarker(mainSessionPath(sessionId, env), "main\n");
7231
+ }
7232
+
7233
+ export function isRecordedMainSession(sessionId, env = process.env) {
7234
+ try {
7235
+ return readSecureHookFile(mainSessionPath(sessionId, env), 16) === "main\n";
7236
+ } catch {
7237
+ return false;
7238
+ }
7239
+ }
7240
+
7241
+ // A child session's parent, as the host named it when it created the child
7242
+ // (OpenCode's session.created parentID): the parent's session id, kept
7243
+ // beside the main-session marker, so every later hook of the child, in any
7244
+ // process, finds the root of its session tree (sessionTreeRoot).
7245
+ const SESSION_TREE_DEPTH_LIMIT = 8;
7246
+
7247
+ function sessionParentPath(sessionId, env) {
7248
+ return join(usageDir(env), `${sessionHash(sessionId)}.parent-session`);
7249
+ }
7250
+
7251
+ export function recordSessionParent(sessionId, parentId, env = process.env) {
7252
+ const parent = normalizeNativeSessionId(parentId);
7253
+ return Boolean(
7254
+ parent &&
7255
+ writePrivateUsageMarker(sessionParentPath(sessionId, env), `${parent}\n`),
7256
+ );
7257
+ }
7258
+
7259
+ // The ledger a session's usage, debts and drafts are kept under. A child
7260
+ // session's is its tree root's agent ledger (<root>:agent:<child>), as a
7261
+ // subagent's is, so its drafts belong to the root's session tree: listed
7262
+ // silently there and to the child itself, never as a user decision to the
7263
+ // child. Any other session's, a child whose root is not proven among them,
7264
+ // is its own id.
7265
+ export function sessionTreeLedgerId(sessionId, env = process.env) {
7266
+ const root = sessionTreeRoot(sessionId, env);
7267
+ return root && normalizeNativeSessionId(sessionId)
7268
+ ? agentLedgerIdFor({ session_id: root, agent_id: sessionId })
7269
+ : sessionId;
7270
+ }
7271
+
7272
+ // The root of a child session's tree, its recorded parent links followed at
7273
+ // most SESSION_TREE_DEPTH_LIMIT times. Null for a session with no recorded
7274
+ // parent, and for a chain that does not end within the bound, which proves
7275
+ // nothing.
7276
+ function sessionTreeRoot(sessionId, env) {
7277
+ let current = sessionId;
7278
+ for (let depth = 0; depth <= SESSION_TREE_DEPTH_LIMIT; depth += 1) {
7279
+ let parent = null;
7280
+ try {
7281
+ parent = normalizeNativeSessionId(
7282
+ readSecureHookFile(sessionParentPath(current, env), 512).trim(),
7283
+ );
7284
+ } catch {
7285
+ // No recorded parent: `current` is the root.
7286
+ }
7287
+ if (!parent) return depth === 0 ? null : current;
7288
+ current = parent;
7289
+ }
7290
+ return null;
7291
+ }
7292
+
6912
7293
  export function recordDirectiveSurface(
6913
7294
  sessionId,
6914
7295
  directive,
@@ -7524,18 +7905,6 @@ export function clearHighMatchSurfaceIfOpened(
7524
7905
  const normalizedTool = String(toolName ?? "")
7525
7906
  .trim()
7526
7907
  .toLowerCase();
7527
- if (
7528
- normalizedTool.endsWith("submit_private_lesson_candidate") ||
7529
- normalizedTool.endsWith("retry_private_lesson_candidate") ||
7530
- normalizedTool.endsWith("submit_private_skill_candidate") ||
7531
- normalizedTool.endsWith("retry_private_skill_candidate")
7532
- ) {
7533
- // The PostToolUse adapter calls this function only after a successful tool
7534
- // response. Clear the content-free denial suppression so future retained
7535
- // drafts can resume normal lifecycle recovery after exact-action approval.
7536
- clearPrivateLessonHostPolicyDenials(env);
7537
- return false;
7538
- }
7539
7908
  const targetType = normalizedTool.endsWith("get_resource")
7540
7909
  ? "resource"
7541
7910
  : normalizedTool.endsWith("get_skill")
@@ -8094,8 +8463,17 @@ export function foldTurnLedgerIntoSession(
8094
8463
  const overflowPath = completionObligationOverflowPath(turn, env);
8095
8464
  // OpenClaw hands a subagent's session ledger up through here, `forward`:
8096
8465
  // it leaves its marker even when it holds nothing, so a nested subagent
8097
- // whose end comes later still reaches where it went.
8098
- if (forward) writeCompletionObligationForward(turn, session, null, env);
8466
+ // whose end comes later still reaches where it went. Its private hints are
8467
+ // held per machine, not in its homes, so the ones it owns become the
8468
+ // session's tree's here (adoptPrivateHint), whatever else it holds.
8469
+ if (forward) {
8470
+ writeCompletionObligationForward(turn, session, null, env);
8471
+ for (const hint of privateHintsOnMachine(turn, env)) {
8472
+ if (privateHintOwnedBy(hint, turn) && !privateHintOwnedBy(hint, session)) {
8473
+ adoptPrivateHint(hint, session, env);
8474
+ }
8475
+ }
8476
+ }
8099
8477
  // Once folded, a turn leaves nothing behind: one check per home per hook.
8100
8478
  // A ledger that only points to debts (an OpenClaw subagent's whose own
8101
8479
  // subagent left them) is folded too.
@@ -8196,141 +8574,226 @@ function privateSkillOutboxDirectoryForHook(env = process.env) {
8196
8574
  );
8197
8575
  }
8198
8576
 
8199
- export function readPrivateLessonLifecycleState(env = process.env) {
8200
- if (!organizationPrivateLessonAuthorized(env)) return null;
8201
- const directory = privateLessonOutboxDirectoryForHook(env);
8577
+ const PRIVATE_LESSON_STATES = [
8578
+ "ready",
8579
+ "awaiting_authorization",
8580
+ "retry_scheduled",
8581
+ "awaiting_compatible_profile",
8582
+ "held_safety",
8583
+ "held_policy",
8584
+ "held_validation",
8585
+ "submitted",
8586
+ "superseded_redactor",
8587
+ ];
8588
+ const PRIVATE_HOLD_TELEMETRY_STATUSES = [
8589
+ "not_required",
8590
+ "pending",
8591
+ "awaiting_authorization",
8592
+ "retry_scheduled",
8593
+ "awaiting_compatible_profile",
8594
+ "submitted",
8595
+ "disabled",
8596
+ ];
8597
+ // The held_policy codes a retry refreshes, as the server's retry gate allows.
8598
+ const PRIVATE_POLICY_REFRESH_CODES = [
8599
+ "policy_expired",
8600
+ "policy_changed",
8601
+ "policy_sequence_conflict",
8602
+ "unsupported_redaction_version",
8603
+ "unsupported_redactor_digest",
8604
+ ];
8605
+
8606
+ function privateLessonEnvelopeValid(value, draftId) {
8607
+ return (
8608
+ value?.version === "private-lesson-outbox-envelope-v1" &&
8609
+ value.draft_id === draftId &&
8610
+ PRIVATE_LESSON_STATES.includes(value.state) &&
8611
+ (value.last_error_code == null ||
8612
+ typeof value.last_error_code === "string") &&
8613
+ PRIVATE_HOLD_TELEMETRY_STATUSES.includes(value.hold_telemetry_status)
8614
+ );
8615
+ }
8616
+
8617
+ // The one exact agent action a lesson draft's content-free envelope allows
8618
+ // now, as the server's retry gate permits it: { tool, count, due }, where
8619
+ // `count` names the lifecycle tally it adds to. Null when only inspection, a
8620
+ // user decision or nothing is left: held_validation, a classified safety
8621
+ // hold (or any safety hold while health reporting is off), a policy hold a
8622
+ // retry cannot refresh, a terminal state. A line, a notice and the lifecycle
8623
+ // counts all come from this one rule.
8624
+ function privateLessonDraftAction(envelope, env) {
8625
+ const submit = "submit_private_lesson_candidate";
8626
+ const retry = "retry_private_lesson_candidate";
8627
+ const whenDue = (count) => ({
8628
+ tool: retry,
8629
+ count,
8630
+ due: retryIsDue(envelope.next_retry_at),
8631
+ });
8632
+ const authorization = () => ({
8633
+ tool: retry,
8634
+ count: "awaiting_authorization",
8635
+ due: privateAuthorizationRetryIsDue(
8636
+ envelope.draft_id,
8637
+ envelope.updated_at,
8638
+ env,
8639
+ ),
8640
+ });
8641
+ const { state } = envelope;
8642
+ if (state === "ready") return { tool: submit, count: "ready", due: true };
8643
+ if (state === "retry_scheduled" || state === "awaiting_compatible_profile")
8644
+ return whenDue(state);
8645
+ if (state === "awaiting_authorization") return authorization();
8646
+ if (state === "held_policy") {
8647
+ return PRIVATE_POLICY_REFRESH_CODES.includes(envelope.last_error_code)
8648
+ ? { tool: retry, count: "held_policy_refresh", due: true }
8649
+ : null;
8650
+ }
8651
+ if (state !== "held_safety" || !privateLessonHealthReportingEnabledForHook(env))
8652
+ return null;
8653
+ const status = envelope.hold_telemetry_status;
8654
+ if (status === "pending")
8655
+ return { tool: submit, count: "hold_telemetry_pending", due: true };
8656
+ if (status === "retry_scheduled" || status === "awaiting_compatible_profile")
8657
+ return whenDue(`hold_telemetry_${status}`);
8658
+ return status === "awaiting_authorization" ? authorization() : null;
8659
+ }
8660
+
8661
+ // One read of an outbox, bounded by its directory: each draft named there
8662
+ // with what `evaluate` makes of its envelope ({ envelope: null } when it
8663
+ // cannot be read or trusted). `readable` is false when no organization
8664
+ // credential applies or the directory itself is refused; nothing is then
8665
+ // known about any draft.
8666
+ function scanPrivateOutbox(env, directory, pattern, maxBytes, evaluate) {
8667
+ const drafts = new Map();
8668
+ if (!privateOutboxReadable(env, directory)) return { readable: false, drafts };
8202
8669
  try {
8203
- const directoryMetadata = lstatSync(directory);
8204
- if (
8205
- directoryMetadata.isSymbolicLink() ||
8206
- !directoryMetadata.isDirectory() ||
8207
- (isPosixHookRuntime() && (directoryMetadata.mode & 0o077) !== 0)
8208
- ) {
8209
- return null;
8210
- }
8211
- assertCurrentHookUser(directoryMetadata.uid);
8212
- const entries = readdirSync(directory, { withFileTypes: true });
8213
- const counts = {
8214
- ready: 0,
8215
- retry_scheduled: 0,
8216
- awaiting_authorization: 0,
8217
- awaiting_compatible_profile: 0,
8218
- held_policy_refresh: 0,
8219
- hold_telemetry_pending: 0,
8220
- hold_telemetry_retry_scheduled: 0,
8221
- hold_telemetry_awaiting_compatible_profile: 0,
8222
- };
8223
- const authorizationIds = [];
8224
- for (const entry of entries) {
8225
- if (!entry.name.endsWith(".lesson.json")) continue;
8670
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
8671
+ const draftId = entry.name.match(pattern)?.[1];
8672
+ if (!draftId) continue;
8673
+ let known = null;
8226
8674
  try {
8227
- if (entry.isSymbolicLink() || !entry.isFile()) continue;
8228
- const expectedDraftId = entry.name.slice(0, -".lesson.json".length);
8229
- if (!/^pld_[A-Za-z0-9_-]{8,96}$/.test(expectedDraftId)) continue;
8230
- const parsed = JSON.parse(
8231
- readSecureHookFile(
8232
- join(directory, entry.name),
8233
- MAX_PRIVATE_LESSON_ENVELOPE_BYTES,
8234
- ),
8235
- );
8236
- if (
8237
- parsed?.version !== "private-lesson-outbox-envelope-v1" ||
8238
- parsed?.draft_id !== expectedDraftId ||
8239
- ![
8240
- "ready",
8241
- "awaiting_authorization",
8242
- "retry_scheduled",
8243
- "awaiting_compatible_profile",
8244
- "held_safety",
8245
- "held_policy",
8246
- "held_validation",
8247
- "submitted",
8248
- "superseded_redactor",
8249
- ].includes(parsed?.state) ||
8250
- (parsed?.last_error_code !== undefined &&
8251
- parsed?.last_error_code !== null &&
8252
- typeof parsed?.last_error_code !== "string") ||
8253
- ![
8254
- "not_required",
8255
- "pending",
8256
- "awaiting_authorization",
8257
- "retry_scheduled",
8258
- "awaiting_compatible_profile",
8259
- "submitted",
8260
- "disabled",
8261
- ].includes(parsed?.hold_telemetry_status)
8262
- ) {
8263
- continue;
8264
- }
8265
- if (
8266
- parsed.state === "ready" ||
8267
- ((parsed.state === "retry_scheduled" ||
8268
- parsed.state === "awaiting_compatible_profile") &&
8269
- retryIsDue(parsed.next_retry_at))
8270
- ) {
8271
- counts[parsed.state] += 1;
8272
- }
8273
- if (
8274
- (parsed.state === "awaiting_authorization" ||
8275
- (parsed.state === "held_safety" &&
8276
- parsed.hold_telemetry_status === "awaiting_authorization" &&
8277
- privateLessonHealthReportingEnabledForHook(env))) &&
8278
- privateAuthorizationRetryIsDue(
8279
- parsed.draft_id,
8280
- parsed.updated_at,
8281
- env,
8282
- )
8283
- ) {
8284
- counts.awaiting_authorization += 1;
8285
- authorizationIds.push(parsed.draft_id);
8286
- }
8287
- if (
8288
- parsed.state === "held_policy" &&
8289
- [
8290
- "policy_expired",
8291
- "policy_changed",
8292
- "policy_sequence_conflict",
8293
- "unsupported_redaction_version",
8294
- "unsupported_redactor_digest",
8295
- ].includes(parsed.last_error_code)
8296
- ) {
8297
- counts.held_policy_refresh += 1;
8298
- }
8299
- if (
8300
- privateLessonHealthReportingEnabledForHook(env) &&
8301
- parsed.state === "held_safety" &&
8302
- parsed.hold_telemetry_status === "pending"
8303
- ) {
8304
- counts.hold_telemetry_pending += 1;
8305
- }
8306
- if (
8307
- privateLessonHealthReportingEnabledForHook(env) &&
8308
- parsed.state === "held_safety" &&
8309
- (parsed.hold_telemetry_status === "retry_scheduled" ||
8310
- parsed.hold_telemetry_status === "awaiting_compatible_profile") &&
8311
- retryIsDue(parsed.next_retry_at)
8312
- ) {
8313
- counts[
8314
- parsed.hold_telemetry_status === "awaiting_compatible_profile"
8315
- ? "hold_telemetry_awaiting_compatible_profile"
8316
- : "hold_telemetry_retry_scheduled"
8317
- ] += 1;
8675
+ if (!entry.isSymbolicLink() && entry.isFile()) {
8676
+ known = evaluate(
8677
+ JSON.parse(readSecureHookFile(join(directory, entry.name), maxBytes)),
8678
+ draftId,
8679
+ );
8318
8680
  }
8319
8681
  } catch {
8320
8682
  // An interrupted or unreadable entry must not hide independent drafts.
8321
8683
  }
8684
+ drafts.set(draftId, known ?? { envelope: null });
8322
8685
  }
8323
- if (Object.values(counts).every((count) => count === 0)) return null;
8324
- return {
8325
- ...counts,
8326
- next_authorization_draft_id: authorizationIds.sort()[0] ?? null,
8327
- host_denial_active: recentPrivateLessonHostDenial(env),
8328
- };
8686
+ return { readable: true, drafts };
8687
+ } catch {
8688
+ return { readable: false, drafts: new Map() };
8689
+ }
8690
+ }
8691
+
8692
+ // Whether an outbox can answer for its drafts: an organization credential
8693
+ // applies and its directory is the current user's own, private one.
8694
+ function privateOutboxReadable(env, directory) {
8695
+ if (!organizationPrivateLessonAuthorized(env)) return false;
8696
+ try {
8697
+ const metadata = lstatSync(directory);
8698
+ if (
8699
+ metadata.isSymbolicLink() ||
8700
+ !metadata.isDirectory() ||
8701
+ (isPosixHookRuntime() && (metadata.mode & 0o077) !== 0)
8702
+ ) {
8703
+ return false;
8704
+ }
8705
+ assertCurrentHookUser(metadata.uid);
8706
+ return true;
8707
+ } catch {
8708
+ return false;
8709
+ }
8710
+ }
8711
+
8712
+ function scanPrivateLessonOutbox(env) {
8713
+ return scanPrivateOutbox(
8714
+ env,
8715
+ privateLessonOutboxDirectoryForHook(env),
8716
+ /^(pld_[A-Za-z0-9_-]{8,96})\.lesson\.json$/,
8717
+ MAX_PRIVATE_LESSON_ENVELOPE_BYTES,
8718
+ (envelope, draftId) =>
8719
+ privateLessonEnvelopeValid(envelope, draftId)
8720
+ ? { envelope, action: privateLessonDraftAction(envelope, env) }
8721
+ : null,
8722
+ );
8723
+ }
8724
+
8725
+ // One draft's envelope, or null when it cannot be read or trusted.
8726
+ function readPrivateLessonEnvelope(draftId, env) {
8727
+ try {
8728
+ const envelope = JSON.parse(
8729
+ readSecureHookFile(
8730
+ join(privateLessonOutboxDirectoryForHook(env), `${draftId}.lesson.json`),
8731
+ MAX_PRIVATE_LESSON_ENVELOPE_BYTES,
8732
+ ),
8733
+ );
8734
+ return privateLessonEnvelopeValid(envelope, draftId) ? envelope : null;
8329
8735
  } catch {
8330
8736
  return null;
8331
8737
  }
8332
8738
  }
8333
8739
 
8740
+ // What a lesson hint's draft allows, as its outbox answers: { action } (null
8741
+ // when no agent action is due from it, or no such draft is retained), or
8742
+ // null when the outbox cannot answer and the hint's recorded fields decide.
8743
+ // `scan` is this prompt's scanPrivateLessonOutbox; without it, one read.
8744
+ function privateLessonHintAction(draftId, env, scan = null) {
8745
+ if (!scan) {
8746
+ const envelope = readPrivateLessonEnvelope(draftId, env);
8747
+ if (envelope) return { action: privateLessonDraftAction(envelope, env) };
8748
+ // A readable outbox without the draft has nothing to act on, as a scan
8749
+ // finds it.
8750
+ const directory = privateLessonOutboxDirectoryForHook(env);
8751
+ try {
8752
+ lstatSync(join(directory, `${draftId}.lesson.json`));
8753
+ return null;
8754
+ } catch (error) {
8755
+ return error?.code === "ENOENT" && privateOutboxReadable(env, directory)
8756
+ ? { action: null }
8757
+ : null;
8758
+ }
8759
+ }
8760
+ if (!scan.readable) return null;
8761
+ const entry = scan.drafts.get(draftId);
8762
+ if (!entry) return { action: null };
8763
+ return entry.envelope ? { action: entry.action } : null;
8764
+ }
8765
+
8766
+ // `scan` (scanPrivateLessonOutbox) may hold only some drafts: the counts are
8767
+ // theirs.
8768
+ export function readPrivateLessonLifecycleState(
8769
+ env = process.env,
8770
+ scan = scanPrivateLessonOutbox(env),
8771
+ ) {
8772
+ if (!scan.readable) return null;
8773
+ const counts = {
8774
+ ready: 0,
8775
+ retry_scheduled: 0,
8776
+ awaiting_authorization: 0,
8777
+ awaiting_compatible_profile: 0,
8778
+ held_policy_refresh: 0,
8779
+ hold_telemetry_pending: 0,
8780
+ hold_telemetry_retry_scheduled: 0,
8781
+ hold_telemetry_awaiting_compatible_profile: 0,
8782
+ };
8783
+ const authorizationIds = [];
8784
+ for (const [draftId, { action }] of scan.drafts) {
8785
+ if (!action?.due) continue;
8786
+ counts[action.count] += 1;
8787
+ if (action.count === "awaiting_authorization") authorizationIds.push(draftId);
8788
+ }
8789
+ if (Object.values(counts).every((count) => count === 0)) return null;
8790
+ return {
8791
+ ...counts,
8792
+ next_authorization_draft_id: authorizationIds.sort()[0] ?? null,
8793
+ host_denial_active: recentPrivateLessonHostDenial(env),
8794
+ };
8795
+ }
8796
+
8334
8797
  function retryIsDue(value) {
8335
8798
  if (!value) return true;
8336
8799
  const timestamp = Date.parse(String(value));
@@ -8730,159 +9193,150 @@ export async function bindPrivateSkillNativeCapture(
8730
9193
  }
8731
9194
  }
8732
9195
 
8733
- export function readPrivateSkillLifecycleState(env = process.env) {
8734
- if (!organizationPrivateLessonAuthorized(env)) return null;
8735
- const directory = privateSkillOutboxDirectoryForHook(env);
8736
- try {
8737
- const directoryMetadata = lstatSync(directory);
9196
+ // The lifecycle tally a private skill draft adds to now (a count key of
9197
+ // readPrivateSkillLifecycleState), from its content-free envelope and
9198
+ // recovery action; null when nothing is due from it.
9199
+ function privateSkillDraftCategory(parsed, env) {
9200
+ const action = privateSkillRecoveryAction(parsed, true);
9201
+ if (
9202
+ parsed.state === "awaiting_authorization" &&
9203
+ privateAuthorizationRetryIsDue(parsed.draft_id, parsed.updated_at, env)
9204
+ ) {
9205
+ return "awaiting_authorization";
9206
+ }
9207
+ const staleInspectorAction =
9208
+ action &&
9209
+ typeof action.inspector_digest === "string" &&
9210
+ action.inspector_digest !== PRIVATE_LEARNING_INSPECTOR_DIGEST &&
9211
+ ((action.kind === "submit_candidate" && parsed.state === "ready") ||
9212
+ (["reformulate", "retry_operational", "capture_native_context"].includes(
9213
+ action.kind,
9214
+ ) &&
9215
+ parsed.state === "held_validation"));
9216
+ const staleInspectorBlockedRecovery =
9217
+ action === null &&
9218
+ parsed.next_action === null &&
9219
+ typeof parsed.inspector_digest === "string" &&
9220
+ parsed.inspector_digest !== PRIVATE_LEARNING_INSPECTOR_DIGEST &&
9221
+ parsed.state === "held_validation" &&
9222
+ parsed.reformulation_available === false &&
9223
+ typeof parsed.reformulation_reason === "string" &&
9224
+ Number.isSafeInteger(parsed.reformulation_attempt_count) &&
9225
+ Number.isSafeInteger(parsed.recovery_epoch_attempt_count) &&
9226
+ parsed.reformulation_attempt_count - parsed.recovery_epoch_attempt_count >=
9227
+ 3;
9228
+ if (staleInspectorAction || staleInspectorBlockedRecovery)
9229
+ return "inspector_refresh";
9230
+ if (action?.kind === "submit_candidate") return "ready";
9231
+ if (action?.kind === "retry_delivery" && retryIsDue(parsed.next_retry_at))
9232
+ return "retry_delivery";
9233
+ if (
9234
+ action?.kind === "retry_resolution" &&
9235
+ (parsed.state === "held_validation" ||
9236
+ (parsed.state === "retry_scheduled" && retryIsDue(parsed.next_retry_at)))
9237
+ ) {
9238
+ return "retry_resolution";
9239
+ }
9240
+ return (
9241
+ {
9242
+ reformulate: "reformulation_available",
9243
+ retry_operational: "operational_retry",
9244
+ reinspect: "inspector_refresh",
9245
+ capture_native_context: "native_context_needed",
9246
+ }[action?.kind] ?? null
9247
+ );
9248
+ }
9249
+
9250
+ function scanPrivateSkillOutbox(env) {
9251
+ return scanPrivateOutbox(
9252
+ env,
9253
+ privateSkillOutboxDirectoryForHook(env),
9254
+ /^(psd_[A-Za-z0-9_-]{8,96})\.json$/,
9255
+ MAX_PRIVATE_SKILL_ENVELOPE_BYTES,
9256
+ (envelope, draftId) =>
9257
+ envelope?.version === "private-skill-outbox-envelope-v1" &&
9258
+ envelope.draft_id === draftId &&
9259
+ [
9260
+ "ready",
9261
+ "awaiting_authorization",
9262
+ "retry_scheduled",
9263
+ "held_safety",
9264
+ "held_validation",
9265
+ ].includes(envelope.state)
9266
+ ? { envelope, category: privateSkillDraftCategory(envelope, env) }
9267
+ : null,
9268
+ );
9269
+ }
9270
+
9271
+ // `scan` (scanPrivateSkillOutbox) may hold only some drafts: the counts are
9272
+ // theirs.
9273
+ export function readPrivateSkillLifecycleState(
9274
+ env = process.env,
9275
+ scan = scanPrivateSkillOutbox(env),
9276
+ ) {
9277
+ if (!scan.readable) return null;
9278
+ const counts = {
9279
+ ready: 0,
9280
+ retry_scheduled: 0,
9281
+ awaiting_authorization: 0,
9282
+ retry_delivery: 0,
9283
+ retry_resolution: 0,
9284
+ reformulation_available: 0,
9285
+ operational_retry: 0,
9286
+ native_context_needed: 0,
9287
+ native_context_pending: 0,
9288
+ inspector_refresh: 0,
9289
+ };
9290
+ const recoveryIds = [];
9291
+ const authorizationIds = [];
9292
+ for (const [draftId, { category }] of scan.drafts) {
9293
+ if (!category) continue;
9294
+ counts[category] += 1;
9295
+ if (category === "awaiting_authorization") authorizationIds.push(draftId);
8738
9296
  if (
8739
- directoryMetadata.isSymbolicLink() ||
8740
- !directoryMetadata.isDirectory() ||
8741
- (isPosixHookRuntime() && (directoryMetadata.mode & 0o077) !== 0)
9297
+ ["inspector_refresh", "reformulation_available", "operational_retry"].includes(
9298
+ category,
9299
+ )
8742
9300
  ) {
8743
- return null;
8744
- }
8745
- assertCurrentHookUser(directoryMetadata.uid);
8746
- const counts = {
8747
- ready: 0,
8748
- retry_scheduled: 0,
8749
- awaiting_authorization: 0,
8750
- retry_delivery: 0,
8751
- retry_resolution: 0,
8752
- reformulation_available: 0,
8753
- operational_retry: 0,
8754
- native_context_needed: 0,
8755
- native_context_pending: 0,
8756
- inspector_refresh: 0,
8757
- };
8758
- const recoveryIds = [];
8759
- const authorizationIds = [];
8760
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
8761
- if (!/^psd_[A-Za-z0-9_-]{8,96}\.json$/.test(entry.name)) continue;
8762
- try {
8763
- if (entry.isSymbolicLink() || !entry.isFile()) continue;
8764
- const expectedDraftId = entry.name.slice(0, -".json".length);
8765
- const parsed = JSON.parse(
8766
- readSecureHookFile(
8767
- join(directory, entry.name),
8768
- MAX_PRIVATE_SKILL_ENVELOPE_BYTES,
8769
- ),
8770
- );
8771
- if (
8772
- parsed?.version !== "private-skill-outbox-envelope-v1" ||
8773
- parsed?.draft_id !== expectedDraftId ||
8774
- ![
8775
- "ready",
8776
- "awaiting_authorization",
8777
- "retry_scheduled",
8778
- "held_safety",
8779
- "held_validation",
8780
- ].includes(parsed?.state)
8781
- ) {
8782
- continue;
8783
- }
8784
- const action = privateSkillRecoveryAction(parsed, true);
8785
- if (
8786
- parsed.state === "awaiting_authorization" &&
8787
- privateAuthorizationRetryIsDue(
8788
- parsed.draft_id,
8789
- parsed.updated_at,
8790
- env,
8791
- )
8792
- ) {
8793
- counts.awaiting_authorization += 1;
8794
- authorizationIds.push(parsed.draft_id);
8795
- continue;
8796
- }
8797
- const staleInspectorAction =
8798
- action &&
8799
- typeof action.inspector_digest === "string" &&
8800
- action.inspector_digest !== PRIVATE_LEARNING_INSPECTOR_DIGEST &&
8801
- ((action.kind === "submit_candidate" && parsed.state === "ready") ||
8802
- ([
8803
- "reformulate",
8804
- "retry_operational",
8805
- "capture_native_context",
8806
- ].includes(action.kind) &&
8807
- parsed.state === "held_validation"));
8808
- const staleInspectorBlockedRecovery =
8809
- action === null &&
8810
- parsed.next_action === null &&
8811
- typeof parsed.inspector_digest === "string" &&
8812
- parsed.inspector_digest !== PRIVATE_LEARNING_INSPECTOR_DIGEST &&
8813
- parsed.state === "held_validation" &&
8814
- parsed.reformulation_available === false &&
8815
- typeof parsed.reformulation_reason === "string" &&
8816
- Number.isSafeInteger(parsed.reformulation_attempt_count) &&
8817
- Number.isSafeInteger(parsed.recovery_epoch_attempt_count) &&
8818
- parsed.reformulation_attempt_count -
8819
- parsed.recovery_epoch_attempt_count >=
8820
- 3;
8821
- if (staleInspectorAction || staleInspectorBlockedRecovery) {
8822
- counts.inspector_refresh += 1;
8823
- recoveryIds.push(parsed.draft_id);
8824
- } else if (action?.kind === "submit_candidate") {
8825
- counts.ready += 1;
8826
- } else if (
8827
- action?.kind === "retry_delivery" &&
8828
- retryIsDue(parsed.next_retry_at)
8829
- ) {
8830
- counts.retry_delivery += 1;
8831
- } else if (
8832
- action?.kind === "retry_resolution" &&
8833
- (parsed.state === "held_validation" ||
8834
- (parsed.state === "retry_scheduled" &&
8835
- retryIsDue(parsed.next_retry_at)))
8836
- ) {
8837
- counts.retry_resolution += 1;
8838
- } else if (action?.kind === "reformulate") {
8839
- counts.reformulation_available += 1;
8840
- recoveryIds.push(parsed.draft_id);
8841
- } else if (action?.kind === "retry_operational") {
8842
- counts.operational_retry += 1;
8843
- recoveryIds.push(parsed.draft_id);
8844
- } else if (action?.kind === "reinspect") {
8845
- counts.inspector_refresh += 1;
8846
- recoveryIds.push(parsed.draft_id);
8847
- } else if (action?.kind === "capture_native_context") {
8848
- counts.native_context_needed += 1;
8849
- }
8850
- } catch {
8851
- // One unsafe or malformed entry cannot hide independent due drafts.
8852
- }
8853
- }
8854
- recoveryIds.sort();
8855
- let cursor = "";
8856
- try {
8857
- const value = readSecureHookFile(
8858
- join(usageDir(env), "private-skill-recovery-cursor"),
8859
- 104,
8860
- );
8861
- if (/^psd_[A-Za-z0-9_-]{8,96}$/.test(value)) cursor = value;
8862
- } catch {
8863
- // A missing/stale hint cannot withhold recoverable drafts.
9301
+ recoveryIds.push(draftId);
8864
9302
  }
8865
- return Object.values(counts).every((count) => count === 0)
8866
- ? null
8867
- : {
8868
- ...counts,
8869
- next_authorization_draft_id: authorizationIds.sort()[0] ?? null,
8870
- next_reformulation_draft_id:
8871
- recoveryIds.find((id) => id > cursor) ?? recoveryIds[0] ?? null,
8872
- host_denial_active: recentPrivateLessonHostDenial(env),
8873
- };
9303
+ }
9304
+ if (Object.values(counts).every((count) => count === 0)) return null;
9305
+ recoveryIds.sort();
9306
+ let cursor = "";
9307
+ try {
9308
+ const value = readSecureHookFile(
9309
+ join(usageDir(env), "private-skill-recovery-cursor"),
9310
+ 104,
9311
+ );
9312
+ if (/^psd_[A-Za-z0-9_-]{8,96}$/.test(value)) cursor = value;
8874
9313
  } catch {
8875
- return null;
9314
+ // A missing/stale hint cannot withhold recoverable drafts.
8876
9315
  }
9316
+ return {
9317
+ ...counts,
9318
+ next_authorization_draft_id: authorizationIds.sort()[0] ?? null,
9319
+ next_reformulation_draft_id:
9320
+ recoveryIds.find((id) => id > cursor) ?? recoveryIds[0] ?? null,
9321
+ host_denial_active: recentPrivateLessonHostDenial(env),
9322
+ };
8877
9323
  }
8878
9324
 
8879
9325
  export function privateSkillNativeContextCaptureNeeded(env = process.env) {
8880
9326
  return (readPrivateSkillLifecycleState(env)?.native_context_needed ?? 0) > 0;
8881
9327
  }
8882
9328
 
9329
+ // The machine-wide recovery, for a caller that names no session.
8883
9330
  export function privateLearningLifecycleRecoveryInstruction(env = process.env) {
8884
- const lessons = readPrivateLessonLifecycleState(env);
8885
- const skills = readPrivateSkillLifecycleState(env);
9331
+ return privateLearningLifecycleText(
9332
+ readPrivateLessonLifecycleState(env),
9333
+ readPrivateSkillLifecycleState(env),
9334
+ );
9335
+ }
9336
+
9337
+ // `own` (privateLearningSessionRecovery): the reading session tree's due
9338
+ // drafts, the only ones its silent recovery may act on.
9339
+ function privateLearningLifecycleText(lessons, skills, own = null) {
8886
9340
  const hostDenialActive = Boolean(
8887
9341
  lessons?.host_denial_active || skills?.host_denial_active,
8888
9342
  );
@@ -8925,7 +9379,18 @@ export function privateLearningLifecycleRecoveryInstruction(env = process.env) {
8925
9379
  return null;
8926
9380
  return [
8927
9381
  `Private learning recovery: ${lessonCandidates} lesson candidate${lessonCandidates === 1 ? "" : "s"}, ${skillCandidates} complete skill candidate${skillCandidates === 1 ? "" : "s"}, ${skillDeliveryRetries} private skill delivery retr${skillDeliveryRetries === 1 ? "y" : "ies"}${authorizationRetries > 0 ? `, ${authorizationRetries} authorization retr${authorizationRetries === 1 ? "y" : "ies"}` : ""}, and ${lessonTelemetry} content-free hold event${lessonTelemetry === 1 ? "" : "s"} are due.`,
8928
- "Silently call inspect_private_learning_outbox. Submit ready lessons with submit_private_lesson_candidate and ready complete skills with submit_private_skill_candidate. Use the matching retry tool only for a due retry.",
9382
+ ...(!own
9383
+ ? [
9384
+ "Silently call inspect_private_learning_outbox. Submit ready lessons with submit_private_lesson_candidate and ready complete skills with submit_private_skill_candidate. Use the matching retry tool only for a due retry.",
9385
+ ]
9386
+ : own.length > 0
9387
+ ? [
9388
+ // One directive unit each, so the context allocator keeps the
9389
+ // drafts with the instruction that names them.
9390
+ `Silently call inspect_private_learning_outbox only for this session's due private drafts (${own.slice(0, 12).join(", ")}${own.length > 12 ? `, and ${own.length - 12} more named in a later prompt` : ""}): submit a ready lesson with submit_private_lesson_candidate and a ready complete skill with submit_private_skill_candidate, and use the matching retry tool only for a due retry.`,
9391
+ "Do not submit or retry, on your own, any other draft inspect_private_learning_outbox lists.",
9392
+ ]
9393
+ : []),
8929
9394
  ...(authorizationReminder ? [authorizationReminder] : []),
8930
9395
  ...(reformulations > 0
8931
9396
  ? [
@@ -8941,6 +9406,543 @@ export function privateLearningLifecycleRecoveryInstruction(env = process.env) {
8941
9406
  ].join("\n");
8942
9407
  }
8943
9408
 
9409
+ // --- Private drafts: ownership, parking and the user decisions ---------------
9410
+ //
9411
+ // Silent recovery names only drafts the reading session tree holds a hint
9412
+ // for. Every other due draft (captured by another session, or by one with no
9413
+ // user prompt for PRIVATE_ORPHAN_AFTER_MS, or by an earlier client, or with
9414
+ // no hint at all) and every parked one reaches a main thread only as one
9415
+ // user decision. Parking (a decline, or a host's denial of that exact draft)
9416
+ // is a content-free marker beside the hints; neither touches a draft, and
9417
+ // only the confirmed delete tool removes one.
9418
+
9419
+ // This prompt's one read of both outboxes and of the drafts' markers.
9420
+ function readPrivateDrafts(env) {
9421
+ const parked = new Map();
9422
+ const held = new Set();
9423
+ const directory = privateLearningLifecycleDirectory(env);
9424
+ if (organizationPrivateLessonAuthorized(env)) {
9425
+ try {
9426
+ assertSecureHookDirectory(lstatSync(directory));
9427
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
9428
+ const match = entry.name.match(
9429
+ /^((?:pld|psd)_[A-Za-z0-9_-]{8,96})\.(parked|held-notice)\.json$/,
9430
+ );
9431
+ if (!match || entry.isSymbolicLink() || !entry.isFile()) continue;
9432
+ if (match[2] === "held-notice") held.add(match[1]);
9433
+ const marker =
9434
+ match[2] === "parked" && readPrivateParkedMarker(match[1], env);
9435
+ if (marker) parked.set(match[1], marker);
9436
+ }
9437
+ } catch {
9438
+ // No lifecycle directory yet: nothing is parked or noticed.
9439
+ }
9440
+ }
9441
+ return {
9442
+ lessons: scanPrivateLessonOutbox(env),
9443
+ skills: scanPrivateSkillOutbox(env),
9444
+ parked,
9445
+ held,
9446
+ };
9447
+ }
9448
+
9449
+ // The obligations due now; a private lesson's line names the exact tool its
9450
+ // envelope allows when its outbox answers.
9451
+ function duePendingActions(obligations, env, drafts) {
9452
+ return obligations
9453
+ .filter((obligation) => completionObligationIsDue(obligation, env, drafts))
9454
+ .map((obligation) => {
9455
+ const tool =
9456
+ obligation.kind === "private_lesson" &&
9457
+ drafts.lessons.drafts.get(obligation.draft_id)?.action?.tool;
9458
+ return tool && tool !== obligation.action_tool
9459
+ ? { ...obligation, action_tool: tool }
9460
+ : obligation;
9461
+ });
9462
+ }
9463
+
9464
+ // A lesson hint whose readable envelope allows no agent action points at
9465
+ // nothing to do, so it goes, and its draft stays. An earlier client's hints
9466
+ // for such drafts (a classified safety hold kept asking for a submit) go on
9467
+ // the first prompt that reads them.
9468
+ function retireUnactionablePrivateLessonHints(hints, drafts, env) {
9469
+ const stale = new Set(
9470
+ hints
9471
+ .filter((hint) => {
9472
+ const entry = drafts.lessons.drafts.get(hint.draft_id);
9473
+ return hint.kind === "private_lesson" && entry?.envelope && !entry.action;
9474
+ })
9475
+ .map((hint) => hint.id),
9476
+ );
9477
+ if (stale.size > 0) {
9478
+ removeCompletionObligations(null, (item) => stale.has(item.id), env);
9479
+ }
9480
+ return stale;
9481
+ }
9482
+
9483
+ function privateHintsOnMachine(sessionId, env) {
9484
+ return readCompletionObligations(sessionId, env, {
9485
+ pointers: false,
9486
+ ask: false,
9487
+ privateHints: "all",
9488
+ }).filter(isPrivateHint);
9489
+ }
9490
+
9491
+ // This ledger's share of the lifecycle recovery for one prompt or stop: its
9492
+ // silent lines count and name only drafts its own session tree holds an
9493
+ // unparked hint for; a main thread also gets the user decisions
9494
+ // (privateDraftNotices), unless the host says it runs this session for a
9495
+ // subagent (`notices: false`). `hints` are every private hint on the
9496
+ // machine. Null when nothing is due.
9497
+ function privateLearningSessionRecovery(
9498
+ sessionId,
9499
+ hints,
9500
+ drafts,
9501
+ env,
9502
+ { notices = true } = {},
9503
+ ) {
9504
+ const own = new Set(
9505
+ hints
9506
+ .filter(
9507
+ (hint) =>
9508
+ privateHintOwnedBy(hint, sessionId) && !drafts.parked.has(hint.draft_id),
9509
+ )
9510
+ .map((hint) => hint.draft_id),
9511
+ );
9512
+ const only = (scan) => ({
9513
+ readable: scan.readable,
9514
+ drafts: new Map([...scan.drafts].filter(([draftId]) => own.has(draftId))),
9515
+ });
9516
+ return (
9517
+ [
9518
+ privateLearningLifecycleText(
9519
+ readPrivateLessonLifecycleState(env, only(drafts.lessons)),
9520
+ readPrivateSkillLifecycleState(env, only(drafts.skills)),
9521
+ // In hint order (created_at, then id), the order of their lines.
9522
+ [...own].filter((draftId) => privateDraftDue(draftId, drafts)),
9523
+ ),
9524
+ ...(notices ? privateDraftNotices(sessionId, hints, drafts, env) : []),
9525
+ ]
9526
+ .filter(Boolean)
9527
+ .join("\n") || null
9528
+ );
9529
+ }
9530
+
9531
+ // The draft ids each notice sentence this process composed for a ledger
9532
+ // names, until a delivery of that exact sentence acknowledges it
9533
+ // (acknowledgePrivateDraftNotices): a delivery marks and advances only what
9534
+ // it showed. Composition and delivery run in one process on every host; two
9535
+ // sessions of one long-lived process (OpenCode, OpenClaw) can compose the
9536
+ // same sentence from different drafts, so each entry is the ledger's. At
9537
+ // most 16, newest last.
9538
+ const privateNoticesShown = new Map();
9539
+
9540
+ function rememberPrivateNotice(sessionId, text, shown) {
9541
+ const ledger = sessionHash(sessionId);
9542
+ const key = `${ledger}\n${text}`;
9543
+ privateNoticesShown.delete(key);
9544
+ privateNoticesShown.set(key, { ...shown, ledger, text });
9545
+ if (privateNoticesShown.size > 16) {
9546
+ privateNoticesShown.delete(privateNoticesShown.keys().next().value);
9547
+ }
9548
+ return text;
9549
+ }
9550
+
9551
+ // The two user decisions, each one bounded, content-free sentence: one
9552
+ // directive unit, which the context allocator keeps whole or drops, so its
9553
+ // exact text in the delivered context acknowledges it
9554
+ // (acknowledgePrivateDraftNotices). Retained drafts awaiting the user
9555
+ // (privateDraftsAwaitingUser), at most once per
9556
+ // PRIVATE_ORPHAN_NOTICE_INTERVAL_MS on this machine, with the action_id that
9557
+ // keeps them; and lesson drafts a safety hold left with nothing to send,
9558
+ // once per draft, with no reply asked. Main threads only.
9559
+ function privateDraftNotices(sessionId, hints, drafts, env) {
9560
+ if (String(sessionId ?? "").includes(":agent:")) return [];
9561
+ const notices = [];
9562
+ const awaiting = privateDraftsAwaitingUser(sessionId, hints, drafts, env);
9563
+ if (awaiting.length > 0 && privateOrphanNoticeDue(env)) {
9564
+ const lessons = awaiting.filter((id) => id.startsWith("pld_")).length;
9565
+ const skills = awaiting.length - lessons;
9566
+ notices.push(
9567
+ rememberPrivateNotice(
9568
+ sessionId,
9569
+ `- Retained private drafts awaiting a user decision: ${lessons} lesson${lessons === 1 ? "" : "s"} and ${skills} skill${skills === 1 ? "" : "s"} that no active session is handling; do not submit them on your own, but ask the user once whether to deliver them (inspect_private_learning_outbox, then each draft's exact submit or retry action), keep them (local decline_pending_action with action_id ${PRIVATE_ORPHAN_NOTICE_ACTION_ID} and any reason), or delete them (the matching delete tool with confirm=true); if no user can answer in this run, leave them in the outbox.`,
9570
+ {
9571
+ awaiting,
9572
+ parked: awaiting.filter((draftId) => drafts.parked.has(draftId)),
9573
+ },
9574
+ ),
9575
+ );
9576
+ }
9577
+ const held = privateHeldDrafts(drafts);
9578
+ if (held.length > 0) {
9579
+ notices.push(
9580
+ rememberPrivateNotice(
9581
+ sessionId,
9582
+ `- ${held.length} retained private lesson draft${held.length === 1 ? " was" : "s were"} held by the local safety check and will not be sent: ask the user once whether to re-capture a safe version with a new capture_learning call or delete ${held.length === 1 ? "it" : "them"} with delete_private_lesson_candidate and confirm=true (inspect_private_learning_outbox lists each draft_id), and never resubmit the held content; this notice is shown once and needs no reply, and if no user can answer in this run, leave the outbox as it is.`,
9583
+ { held },
9584
+ ),
9585
+ );
9586
+ }
9587
+ return notices;
9588
+ }
9589
+
9590
+ // Due drafts no session of the reader's tree holds a hint for: none or an
9591
+ // earlier client's, another session's whose hint is idle
9592
+ // (privateHintIdle), and parked drafts, whoever holds them, whose next
9593
+ // notice is due. In draft id order, whatever order the file system lists
9594
+ // them in.
9595
+ function privateDraftsAwaitingUser(sessionId, hints, drafts, env) {
9596
+ const own = sessionHash(sessionId);
9597
+ const held = new Map(hints.map((hint) => [hint.draft_id, hint]));
9598
+ const idle = new Map();
9599
+ const isIdle = (ledger) => {
9600
+ if (!idle.has(ledger)) idle.set(ledger, privateSessionIdle(ledger, env));
9601
+ return idle.get(ledger);
9602
+ };
9603
+ const awaiting = [];
9604
+ for (const draftId of [...drafts.lessons.drafts.keys(), ...drafts.skills.drafts.keys()]) {
9605
+ if (!privateDraftDue(draftId, drafts)) continue;
9606
+ const parked = drafts.parked.get(draftId);
9607
+ const hint = held.get(draftId);
9608
+ const owner = hint?.owner_session;
9609
+ if (
9610
+ parked
9611
+ ? privateParkedNoticeDue(parked)
9612
+ : owner !== own && (!owner || privateHintIdle(hint, isIdle))
9613
+ ) {
9614
+ awaiting.push(draftId);
9615
+ }
9616
+ }
9617
+ return awaiting.sort();
9618
+ }
9619
+
9620
+ // Whether a draft allows an agent action now (the native-context wait asks
9621
+ // for none).
9622
+ function privateDraftDue(draftId, drafts) {
9623
+ if (draftId.startsWith("pld_"))
9624
+ return Boolean(drafts.lessons.drafts.get(draftId)?.action?.due);
9625
+ const category = drafts.skills.drafts.get(draftId)?.category;
9626
+ return Boolean(category && category !== "native_context_needed");
9627
+ }
9628
+
9629
+ // A hint's draft is still being handled while its owner's session or the
9630
+ // ledger that captured it has had a user prompt within
9631
+ // PRIVATE_ORPHAN_AFTER_MS: an OpenCode child's own prompts (its tree ledger)
9632
+ // keep its drafts while its root is idle. A ledger with no count, such as a
9633
+ // Claude Code subagent's, is idle, so there the owner decides as before.
9634
+ // One more stat at most, only when the owner is idle.
9635
+ function privateHintIdle(hint, isIdle) {
9636
+ return (
9637
+ isIdle(hint.owner_session) &&
9638
+ (hint.captured_by_ledger === hint.owner_session ||
9639
+ isIdle(hint.captured_by_ledger))
9640
+ );
9641
+ }
9642
+
9643
+ function privateSessionIdle(owner, env) {
9644
+ try {
9645
+ return (
9646
+ lstatSync(join(usageDir(env), `${owner}.user-prompts`)).mtimeMs +
9647
+ PRIVATE_ORPHAN_AFTER_MS <=
9648
+ Date.now()
9649
+ );
9650
+ } catch {
9651
+ return true;
9652
+ }
9653
+ }
9654
+
9655
+ // Lesson drafts a safety hold left with no agent action (classified, or
9656
+ // health reporting off) that have not had their one notice.
9657
+ function privateHeldDrafts(drafts) {
9658
+ return [...drafts.lessons.drafts]
9659
+ .filter(
9660
+ ([draftId, { envelope, action }]) =>
9661
+ envelope?.state === "held_safety" && !action && !drafts.held.has(draftId),
9662
+ )
9663
+ .map(([draftId]) => draftId);
9664
+ }
9665
+
9666
+ function privateOrphanNoticePath(env) {
9667
+ return join(privateLearningLifecycleDirectory(env), "orphan-notice.json");
9668
+ }
9669
+
9670
+ // The last delivered notice for drafts awaiting the user: when, and the
9671
+ // draft ids it offered while they are at most PRIVATE_ORPHAN_NOTICE_DRAFT_LIMIT,
9672
+ // so the record stays within the bytes read back; a longer offer is recorded
9673
+ // without them. Content-free.
9674
+ const PRIVATE_ORPHAN_NOTICE_DRAFT_LIMIT = 256;
9675
+
9676
+ function readPrivateOrphanNotice(env) {
9677
+ try {
9678
+ const value = JSON.parse(
9679
+ readSecureHookFile(privateOrphanNoticePath(env), 32 * 1024),
9680
+ );
9681
+ return {
9682
+ shown_at: Date.parse(value?.shown_at ?? ""),
9683
+ drafts: Array.isArray(value?.drafts)
9684
+ ? value.drafts.filter((draftId) =>
9685
+ /^(?:pld|psd)_[A-Za-z0-9_-]{8,96}$/.test(draftId ?? ""),
9686
+ )
9687
+ : null,
9688
+ };
9689
+ } catch {
9690
+ return null;
9691
+ }
9692
+ }
9693
+
9694
+ function privateOrphanNoticeDue(env) {
9695
+ const shown = readPrivateOrphanNotice(env)?.shown_at;
9696
+ // A future time cannot strand the notice.
9697
+ return (
9698
+ !Number.isFinite(shown) ||
9699
+ shown > Date.now() ||
9700
+ shown + PRIVATE_ORPHAN_NOTICE_INTERVAL_MS <= Date.now()
9701
+ );
9702
+ }
9703
+
9704
+ function readPrivateParkedMarker(draftId, env) {
9705
+ const path = privateDraftMarkerPath(draftId, "parked", env);
9706
+ if (!path) return null;
9707
+ try {
9708
+ const marker = JSON.parse(readSecureHookFile(path, 1024));
9709
+ return marker?.version === 1 &&
9710
+ Number.isFinite(Date.parse(marker.parked_at)) &&
9711
+ Number.isSafeInteger(marker.notices) &&
9712
+ marker.notices >= 0
9713
+ ? marker
9714
+ : null;
9715
+ } catch {
9716
+ return null;
9717
+ }
9718
+ }
9719
+
9720
+ function writePrivateParkedMarker(draftId, marker, env) {
9721
+ const path = privateDraftMarkerPath(draftId, "parked", env);
9722
+ return Boolean(
9723
+ path &&
9724
+ writePrivateUsageMarker(path, `${JSON.stringify({ version: 1, ...marker })}\n`),
9725
+ );
9726
+ }
9727
+
9728
+ // When a parked draft's notice is next due after `notices` deliveries: on
9729
+ // the PRIVATE_PARKED_NOTICE_DELAYS_MS schedule, then never on its own.
9730
+ function privateParkedNoticeAt(notices, now = Date.now()) {
9731
+ return notices < PRIVATE_PARKED_NOTICE_DELAYS_MS.length
9732
+ ? new Date(now + PRIVATE_PARKED_NOTICE_DELAYS_MS[notices]).toISOString()
9733
+ : null;
9734
+ }
9735
+
9736
+ // A parked draft's notice is due on its schedule, and once after a client
9737
+ // update.
9738
+ function privateParkedNoticeDue(marker) {
9739
+ const next = Date.parse(marker.next_notice_at ?? "");
9740
+ const installed = installedClientVersion();
9741
+ return (
9742
+ (Number.isFinite(next) && next <= Date.now()) ||
9743
+ Boolean(
9744
+ installed &&
9745
+ marker.client_version &&
9746
+ compareStableClientVersions(installed, marker.client_version) > 0,
9747
+ )
9748
+ );
9749
+ }
9750
+
9751
+ // The client version a parked marker records: the newest that showed or
9752
+ // parked its draft. A host of an older version on the machine never moves
9753
+ // it back, so a newer host does not take its notice or park for an update.
9754
+ function newerClientVersion(recorded) {
9755
+ const installed = installedClientVersion();
9756
+ return compareStableClientVersions(recorded, installed) > 0
9757
+ ? recorded
9758
+ : (installed ?? recorded ?? null);
9759
+ }
9760
+
9761
+ // Parks one draft: its line stops and it returns only in the user-decision
9762
+ // notice, on its schedule. A repeated park keeps the count already shown.
9763
+ // The parked marker now holds the draft, so its hint goes, whoever held it,
9764
+ // and leaves room in the machine's bound; its authorization backoff stays,
9765
+ // and an explicit submit or retry records a hint again.
9766
+ function parkPrivateDraft(
9767
+ draftId,
9768
+ reason,
9769
+ env,
9770
+ prior = readPrivateParkedMarker(draftId, env),
9771
+ ) {
9772
+ const notices = prior?.notices ?? 0;
9773
+ const parked = writePrivateParkedMarker(
9774
+ draftId,
9775
+ {
9776
+ reason,
9777
+ parked_at: new Date().toISOString(),
9778
+ notices,
9779
+ next_notice_at: privateParkedNoticeAt(notices),
9780
+ client_version: newerClientVersion(prior?.client_version),
9781
+ },
9782
+ env,
9783
+ );
9784
+ if (parked) dropPrivateHint(draftId, env);
9785
+ return parked;
9786
+ }
9787
+
9788
+ // Removes a draft's hint where any client kept it, and nothing else of it:
9789
+ // not its draft, not its markers.
9790
+ function dropPrivateHint(draftId, env) {
9791
+ const id = `${draftId.startsWith("pld_") ? "private_lesson" : "private_skill"}:${draftId}`;
9792
+ try {
9793
+ if (lifecycleDirectoryUsable(env)) {
9794
+ rmSync(completionObligationPath(null, id, env), { force: true });
9795
+ }
9796
+ rmSync(
9797
+ join(
9798
+ legacyPrivateCompletionObligationDir(env),
9799
+ completionObligationFileName(id),
9800
+ ),
9801
+ { force: true },
9802
+ );
9803
+ } catch {
9804
+ // A hint left behind is only a pointer; its parked draft stays parked.
9805
+ }
9806
+ }
9807
+
9808
+ // An observed submit or retry the host let through allows its lane again:
9809
+ // the lane-wide denial observations go, and each draft of that lane parked
9810
+ // for a host denial returns once, as a user decision.
9811
+ function allowPrivateLane(prefix, env) {
9812
+ clearPrivateLessonHostPolicyDenials(env);
9813
+ try {
9814
+ for (const name of readdirSync(privateLearningLifecycleDirectory(env))) {
9815
+ const draftId = name.match(/^((?:pld|psd)_[A-Za-z0-9_-]{8,96})\.parked\.json$/)?.[1];
9816
+ if (!draftId?.startsWith(prefix)) continue;
9817
+ const marker = readPrivateParkedMarker(draftId, env);
9818
+ if (marker?.reason !== PRIVATE_DECLINE_REASON || marker.lane_allowed_at)
9819
+ continue;
9820
+ const now = new Date().toISOString();
9821
+ writePrivateParkedMarker(
9822
+ draftId,
9823
+ { ...marker, next_notice_at: now, lane_allowed_at: now },
9824
+ env,
9825
+ );
9826
+ }
9827
+ } catch {
9828
+ // Nothing parked yet.
9829
+ }
9830
+ }
9831
+
9832
+ // A notice counts as shown once its sentence reached the agent, as a retry
9833
+ // reference does, and only for the drafts that sentence was composed from
9834
+ // (privateNoticesShown): the delivering ledger's composition, or, where the
9835
+ // host names none (one session per process), the newest. Nothing else is
9836
+ // read. The orphan interval restarts with the drafts it offered recorded,
9837
+ // each parked draft it named moves to its next wait, once: one another
9838
+ // delivery already moved, or a park restarted, is no longer due. Each held
9839
+ // draft it counted has had its one notice.
9840
+ function acknowledgePrivateDraftNotices(context, env, ledgerId) {
9841
+ const ledger = ledgerId == null ? null : sessionHash(ledgerId);
9842
+ const delivered = new Set();
9843
+ for (const [key, shown] of [...privateNoticesShown].reverse()) {
9844
+ if (
9845
+ !context.includes(shown.text) ||
9846
+ (ledger ? shown.ledger !== ledger : delivered.has(shown.text))
9847
+ ) {
9848
+ continue;
9849
+ }
9850
+ delivered.add(shown.text);
9851
+ privateNoticesShown.delete(key);
9852
+ const now = new Date().toISOString();
9853
+ for (const draftId of shown.held ?? []) {
9854
+ writePrivateUsageMarker(
9855
+ privateDraftMarkerPath(draftId, "held-notice", env),
9856
+ `${JSON.stringify({ version: 1, shown_at: now })}\n`,
9857
+ );
9858
+ }
9859
+ if (!shown.awaiting) continue;
9860
+ writePrivateUsageMarker(
9861
+ privateOrphanNoticePath(env),
9862
+ `${JSON.stringify({
9863
+ version: 1,
9864
+ shown_at: now,
9865
+ ...(shown.awaiting.length <= PRIVATE_ORPHAN_NOTICE_DRAFT_LIMIT
9866
+ ? { drafts: shown.awaiting }
9867
+ : {}),
9868
+ })}\n`,
9869
+ );
9870
+ for (const draftId of shown.parked) {
9871
+ const marker = readPrivateParkedMarker(draftId, env);
9872
+ if (!marker || !privateParkedNoticeDue(marker)) continue;
9873
+ const notices = marker.notices + 1;
9874
+ writePrivateParkedMarker(
9875
+ draftId,
9876
+ {
9877
+ ...marker,
9878
+ notices,
9879
+ next_notice_at: privateParkedNoticeAt(notices),
9880
+ client_version: newerClientVersion(marker.client_version),
9881
+ },
9882
+ env,
9883
+ );
9884
+ }
9885
+ }
9886
+ }
9887
+
9888
+ // A decline of the notice for drafts awaiting the user parks the drafts it
9889
+ // offered that still await the user; a draft that reached the user after it
9890
+ // was delivered, or one the user had delivered meanwhile, is not parked.
9891
+ // Without a recorded offer (none was delivered, or it offered more drafts
9892
+ // than the record keeps), every draft awaiting the user now that is not
9893
+ // parked yet is: one that began awaiting after the delivery then returns as
9894
+ // a user decision a day later, about when the next notice would have
9895
+ // offered it. A parked draft keeps its own schedule and comes with the next
9896
+ // notice: a delivery moves on each parked draft it shows, so one due now was
9897
+ // not in the offer.
9898
+ function parkPrivateDraftsAwaitingUser(sessionId, reason, env) {
9899
+ const drafts = readPrivateDrafts(env);
9900
+ const offered = readPrivateOrphanNotice(env)?.drafts;
9901
+ for (const draftId of privateDraftsAwaitingUser(
9902
+ sessionId,
9903
+ privateHintsOnMachine(sessionId, env),
9904
+ drafts,
9905
+ env,
9906
+ )) {
9907
+ if (offered ? !offered.includes(draftId) : drafts.parked.has(draftId)) continue;
9908
+ parkPrivateDraft(draftId, reason, env, drafts.parked.get(draftId));
9909
+ }
9910
+ }
9911
+
9912
+ // The draft a host denied, when its event names the exact private submit or
9913
+ // retry tool and that call's own draft_id.
9914
+ function privateDeniedDraftId(toolName, value) {
9915
+ const prefix = {
9916
+ submit_private_lesson_candidate: "pld_",
9917
+ retry_private_lesson_candidate: "pld_",
9918
+ submit_private_skill_candidate: "psd_",
9919
+ retry_private_skill_candidate: "psd_",
9920
+ }[normalizedCompletionToolName(toolName)];
9921
+ if (!prefix || !value || typeof value !== "object") return null;
9922
+ let args =
9923
+ value.tool_input ??
9924
+ value.toolInput ??
9925
+ value.arguments ??
9926
+ value.args ??
9927
+ value.params;
9928
+ if (typeof args === "string") {
9929
+ try {
9930
+ args = JSON.parse(args);
9931
+ } catch {
9932
+ return null;
9933
+ }
9934
+ }
9935
+ const draftId =
9936
+ args && typeof args === "object" && !Array.isArray(args)
9937
+ ? args.draft_id
9938
+ : null;
9939
+ return typeof draftId === "string" &&
9940
+ draftId.startsWith(prefix) &&
9941
+ /^(?:pld|psd)_[A-Za-z0-9_-]{8,96}$/.test(draftId)
9942
+ ? draftId
9943
+ : null;
9944
+ }
9945
+
8944
9946
  export function privateLessonLifecycleRecoveryInstruction(env = process.env) {
8945
9947
  return privateLearningLifecycleRecoveryInstruction(env);
8946
9948
  }
@@ -10448,6 +11450,13 @@ export function recordHostPolicyDenial(
10448
11450
  toolName,
10449
11451
  value,
10450
11452
  });
11453
+ // The exact private submit or retry denied parks that one draft, by the
11454
+ // tool and its own draft_id rather than by wording: always for a host
11455
+ // permission denial, and for a generic failure the classifier accepted.
11456
+ const deniedDraft = privateDeniedDraftId(toolName, value);
11457
+ if (deniedDraft && (classification || eventType === "PermissionDenied")) {
11458
+ parkPrivateDraft(deniedDraft, PRIVATE_DECLINE_REASON, env);
11459
+ }
10451
11460
  if (!normalizedSurface || !classification) return null;
10452
11461
  // Hosts that expose denial events can acknowledge the exact receipt locally.
10453
11462
  // Other hosts use the explicit local tool; no transcript inference is needed.