@akagilnc/pi-workflow-roles 0.1.2041 → 0.1.2048

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.
@@ -33,6 +33,7 @@ import {
33
33
  import { loadCanonicalSkillBinding as loadHomeCanonicalSkillBinding } from "../src/canonical-skill-binding.ts";
34
34
  import { loadPackagedCanonicalSkillBinding } from "../src/package-resources/method-skill-binding.ts";
35
35
  import { JUDGE_OUTPUT_TOOL_NAME } from "../src/package-contracts/judge-output.ts";
36
+ import { readOAuthKeepaliveProviders } from "../src/oauth-keepalive.ts";
36
37
  import {
37
38
  createProductionMergerGitState,
38
39
  createRoleRuntimeExtension,
@@ -244,7 +245,11 @@ export default function roleRuntime(pi: ExtensionAPI): void {
244
245
  const reviewerAgent = createReviewerAgentRunner();
245
246
  registerNavigatorModelCommand(pi);
246
247
  const navigatorSessionFactory = createNativeNavigatorSessionFactory();
248
+ // #351: static provider list from extension setting (default ["kimi-coding"]).
249
+ // Production root is the sole reader; keepalive never auto-detects providers.
250
+ const oauthKeepaliveProviders = readOAuthKeepaliveProviders();
247
251
  createRoleRuntimeExtension({
252
+ oauthKeepalive: { providers: oauthKeepaliveProviders },
248
253
  loadJudgeSoul: () => readFile(judgeSoulPath, "utf8"),
249
254
  loadFixerSoul: () => readFile(fixerSoulPath, "utf8"),
250
255
  loadFixPacket: (path) => readFile(path, "utf8"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.2041",
3
+ "version": "0.1.2048",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Extension-side OAuth credential keepalive (#351).
3
+ *
4
+ * Periodically calls the public `ctx.modelRegistry.refresh` facade so expired
5
+ * OAuth tokens (e.g. kimi-coding 900s TTL) are renewed during long sessions.
6
+ * Does not read auth.json, touch pi-ai private APIs, or reuse the 183s idle gate.
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ export const DEFAULT_OAUTH_KEEPALIVE_PROVIDERS = ["kimi-coding"] as const;
14
+
15
+ export const OAUTH_KEEPALIVE_INTERVAL_MS = 60_000;
16
+
17
+ /** Persistent extension setting filename under PI_CODING_AGENT_DIR / ~/.pi/agent. */
18
+ export const OAUTH_KEEPALIVE_SETTING_FILENAME = "oauth-keepalive.json";
19
+
20
+ /** Path to the static provider-list extension setting (default ["kimi-coding"]). */
21
+ export function oauthKeepaliveSettingPath(): string {
22
+ return join(
23
+ process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"),
24
+ OAUTH_KEEPALIVE_SETTING_FILENAME,
25
+ );
26
+ }
27
+
28
+ function isExactRecord(value: unknown): value is Record<string, unknown> {
29
+ return typeof value === "object" && value !== null && !Array.isArray(value);
30
+ }
31
+
32
+ /**
33
+ * Read the static keepalive provider list from the extension setting file.
34
+ * Missing file → default ["kimi-coding"]. Malformed content fails closed.
35
+ * Production extension root is the sole caller that feeds this into keepalive.
36
+ */
37
+ export function readOAuthKeepaliveProviders(
38
+ path = oauthKeepaliveSettingPath(),
39
+ ): readonly string[] {
40
+ try {
41
+ const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
42
+ if (!isExactRecord(raw) || !Array.isArray(raw.providers)) {
43
+ throw new Error(
44
+ "OAuth keepalive setting is malformed: expected { providers: string[] }",
45
+ );
46
+ }
47
+ const providers = raw.providers.map((id, index) => {
48
+ if (typeof id !== "string" || id.trim() === "") {
49
+ throw new Error(
50
+ `OAuth keepalive setting providers[${index}] must be a non-empty string`,
51
+ );
52
+ }
53
+ // Accept via trim-non-empty; return the normalized id so refresh can hit real providers.
54
+ return id.trim();
55
+ });
56
+ return Object.freeze(providers);
57
+ } catch (error) {
58
+ // Absent optional setting uses the documented default; all other causes propagate.
59
+ if (
60
+ error instanceof Error &&
61
+ "code" in error &&
62
+ (error as NodeJS.ErrnoException).code === "ENOENT"
63
+ ) {
64
+ return DEFAULT_OAUTH_KEEPALIVE_PROVIDERS;
65
+ }
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ /** Injected interval scheduler (same shape as TrajectoryScheduler). */
71
+ export type OAuthKeepaliveScheduler = {
72
+ /** Schedule `tick` every `ms` milliseconds; return a cancel function. */
73
+ every: (ms: number, tick: () => void) => () => void;
74
+ };
75
+
76
+ export type OAuthKeepaliveOptions = {
77
+ /** Static provider id list; frozen at construction. Default: ["kimi-coding"]. */
78
+ providers?: readonly string[];
79
+ /** Interval between ticks in ms. Default: 60_000. First tick is after one interval. */
80
+ intervalMs?: number;
81
+ /** Injectable scheduler for tests. Default: setInterval + unref. */
82
+ scheduler?: OAuthKeepaliveScheduler;
83
+ };
84
+
85
+ /** Minimal context surface used by keepalive (public extension facade only). */
86
+ export type OAuthKeepaliveContext = {
87
+ modelRegistry: {
88
+ refresh: (options?: {
89
+ allowNetwork?: boolean;
90
+ providers?: readonly string[];
91
+ force?: boolean;
92
+ signal?: AbortSignal;
93
+ }) => Promise<{
94
+ aborted: boolean;
95
+ errors: ReadonlyMap<string, Error>;
96
+ }>;
97
+ };
98
+ ui?: {
99
+ notify?: (message: string, type?: "info" | "warning" | "error") => void;
100
+ };
101
+ };
102
+
103
+ export type OAuthKeepalive = {
104
+ start(ctx: OAuthKeepaliveContext): void;
105
+ stop(): void;
106
+ };
107
+
108
+ const defaultScheduler: OAuthKeepaliveScheduler = {
109
+ every(ms, tick) {
110
+ const timer = setInterval(tick, ms);
111
+ // Allow the process to exit if a caller forgets stop (same discipline as trajectory).
112
+ timer.unref?.();
113
+ return () => clearInterval(timer);
114
+ },
115
+ };
116
+
117
+ function errorClassName(error: unknown): string {
118
+ if (error instanceof Error && error.name.trim() !== "") return error.name;
119
+ if (error !== null && typeof error === "object" && "name" in error) {
120
+ const name = (error as { name?: unknown }).name;
121
+ if (typeof name === "string" && name.trim() !== "") return name;
122
+ }
123
+ return "Error";
124
+ }
125
+
126
+ function formatWarning(providerId: string, error: unknown): string {
127
+ const cls = errorClassName(error);
128
+ const detail =
129
+ error instanceof Error
130
+ ? error.message
131
+ : typeof error === "string"
132
+ ? error
133
+ : String(error);
134
+ return `oauth-keepalive: refresh failed for provider ${providerId} (${cls}): ${detail}`;
135
+ }
136
+
137
+ function emitWarning(ctx: OAuthKeepaliveContext, providerId: string, error: unknown): void {
138
+ const text = formatWarning(providerId, error);
139
+ // Exactly one visible surface per failure: prefer ui.notify when present;
140
+ // console.warn only when notify is unavailable (print/json / headless)
141
+ // or when notify itself throws (still one surface; carry the notify cause).
142
+ const notify = ctx.ui?.notify;
143
+ if (typeof notify === "function") {
144
+ try {
145
+ notify(text, "warning");
146
+ } catch (notifyError) {
147
+ // Notify threw: fall back once so refresh failure stays visible.
148
+ console.warn(text, notifyError);
149
+ }
150
+ return;
151
+ }
152
+ console.warn(text);
153
+ }
154
+
155
+ /**
156
+ * Session-lifecycle owner for periodic OAuth refresh via the public modelRegistry facade.
157
+ * Single-flight: overlapping ticks are skipped. stop() cancels the interval and aborts in-flight work.
158
+ */
159
+ export function createOAuthKeepalive(options: OAuthKeepaliveOptions = {}): OAuthKeepalive {
160
+ const providers = Object.freeze(
161
+ (options.providers ?? DEFAULT_OAUTH_KEEPALIVE_PROVIDERS).map((id) => id),
162
+ ) as readonly string[];
163
+ const intervalMs = options.intervalMs ?? OAUTH_KEEPALIVE_INTERVAL_MS;
164
+ const scheduler = options.scheduler ?? defaultScheduler;
165
+
166
+ let cancel: (() => void) | undefined;
167
+ let abortController: AbortController | undefined;
168
+ /** Bare single-flight gate; survives stop/start until the in-flight call settles. */
169
+ let inFlight = false;
170
+
171
+ const stop = (): void => {
172
+ cancel?.();
173
+ cancel = undefined;
174
+ abortController?.abort();
175
+ abortController = undefined;
176
+ // Do not clear unsettled inFlight — single-flight is continuous across stop/start.
177
+ };
178
+
179
+ const runTick = async (ctx: OAuthKeepaliveContext): Promise<void> => {
180
+ if (inFlight) return;
181
+ const ac = abortController;
182
+ if (ac === undefined || ac.signal.aborted) return;
183
+ inFlight = true;
184
+ try {
185
+ const result = await ctx.modelRegistry.refresh({
186
+ providers: [...providers],
187
+ allowNetwork: true,
188
+ signal: ac.signal,
189
+ });
190
+ if (ac.signal.aborted || result.aborted) return;
191
+ for (const [providerId, error] of result.errors) {
192
+ emitWarning(ctx, providerId, error);
193
+ }
194
+ } catch (error) {
195
+ if (ac.signal.aborted) return;
196
+ // Whole-call failure: one warning per configured provider (ticket error surface).
197
+ for (const providerId of providers) {
198
+ emitWarning(ctx, providerId, error);
199
+ }
200
+ } finally {
201
+ inFlight = false;
202
+ }
203
+ };
204
+
205
+ return {
206
+ start(ctx) {
207
+ // Reload / re-start: tear down any prior interval before hanging a new one.
208
+ stop();
209
+ abortController = new AbortController();
210
+ cancel = scheduler.every(intervalMs, () => {
211
+ void runTick(ctx);
212
+ });
213
+ },
214
+ stop,
215
+ };
216
+ }
@@ -24,6 +24,7 @@ import {
24
24
  import { installPackageOwnedToolRegistration } from "./package-owned-tool-idle.ts";
25
25
  import { installWorkerGitHooks } from "./worker-submission-gates.ts";
26
26
  import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT } from "./receipt-delivery-policy.ts";
27
+ import { createOAuthKeepalive, type OAuthKeepaliveOptions } from "./oauth-keepalive.ts";
27
28
 
28
29
  import type { AnyCanonicalSkillBinding } from "./canonical-skill-binding.ts";
29
30
  import type { CollectorClock } from "./collector-evidence.ts";
@@ -506,6 +507,12 @@ export type RoleRuntimeDependencies = {
506
507
  /** Monotonic ms clock for update throttling; defaults to performance.now (not Date.now). */
507
508
  toolExecutionObservationMonoNow?(): number;
508
509
  toolExecutionObservationWriter?: ToolExecutionObservationWriter;
510
+ /**
511
+ * #351 OAuth keepalive: providers/interval/scheduler.
512
+ * Production extension root reads oauth-keepalive.json and passes providers here
513
+ * (default ["kimi-coding"] when the setting file is absent). Tests may inject.
514
+ */
515
+ oauthKeepalive?: OAuthKeepaliveOptions;
509
516
  };
510
517
 
511
518
  function abortContext(ctx: ExtensionContext): void {
@@ -577,6 +584,8 @@ export function createRoleRuntimeExtension(
577
584
  let reviewerOriginalRequest: string | undefined;
578
585
  let reviewerExpansionCaptured = false;
579
586
  let navigatorAttendance: NavigatorAttendanceDependency | undefined;
587
+ // #351: session-lifecycle owner for periodic OAuth refresh (orthogonal to role admission).
588
+ const oauthKeepalive = createOAuthKeepalive(dependencies.oauthKeepalive);
580
589
  let pendingNavigatorPresentation: { event: import("./navigator-attendance.ts").NavigatorEvent; report: import("./navigator-attendance.ts").NavigatorReport } | undefined;
581
590
  let pendingNavigatorSettlement: Promise<void> | undefined;
582
591
  let navigatorWorkContext: NavigatorWorkContext | undefined;
@@ -809,6 +818,8 @@ export function createRoleRuntimeExtension(
809
818
  }, { triggerTurn: false });
810
819
  });
811
820
  pi.on("session_shutdown", async () => {
821
+ // #351: stop OAuth keepalive first so shutdown yields zero further ticks.
822
+ oauthKeepalive.stop();
812
823
  // Flush any still-pending affirmative attendance before teardown. Accepted
813
824
  // grace-timeout paths normally emit on agent_settled; abort can skip that hook.
814
825
  const presentation = pendingNavigatorPresentation;
@@ -1043,6 +1054,9 @@ export function createRoleRuntimeExtension(
1043
1054
  pendingNavigatorSettlement = undefined;
1044
1055
  pendingInfrastructureToolCallIds.clear();
1045
1056
  navigatorWorkContext = undefined;
1057
+ // #351: OAuth keepalive is orthogonal to --ak-role; start before role early-return
1058
+ // so role-less sessions (and reload after shutdown stop) still keep tokens alive.
1059
+ oauthKeepalive.start(ctx);
1046
1060
  const rawRole = pi.getFlag(ROLE_FLAG.name);
1047
1061
  if (rawRole === undefined) return;
1048
1062
  const entry = PACKAGED_ROLE_REGISTRY.find(({ role }) => role === rawRole);