@gajae-code/coding-agent 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/types/commands/ultragoal.d.ts +1 -0
- package/dist/types/config/settings-schema.d.ts +11 -3
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
- package/dist/types/harness-control-plane/finalize.d.ts +8 -0
- package/dist/types/harness-control-plane/receipts.d.ts +16 -1
- package/dist/types/harness-control-plane/types.d.ts +9 -1
- package/dist/types/modes/rpc/rpc-client.d.ts +11 -1
- package/dist/types/reminders/star-reminder.d.ts +115 -0
- package/dist/types/session/agent-session.d.ts +18 -0
- package/examples/extensions/README.md +20 -41
- package/package.json +7 -7
- package/src/cli/grep-cli.ts +1 -1
- package/src/commands/harness.ts +42 -3
- package/src/commands/ultragoal.ts +1 -0
- package/src/config/settings-schema.ts +13 -3
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +19 -23
- package/src/defaults/gjc/skills/ralplan/SKILL.md +7 -7
- package/src/defaults/gjc/skills/team/SKILL.md +10 -1
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +3 -2
- package/src/gjc-runtime/launch-tmux.ts +25 -2
- package/src/gjc-runtime/team-runtime.ts +78 -3
- package/src/gjc-runtime/ultragoal-guard.ts +18 -2
- package/src/gjc-runtime/ultragoal-runtime.ts +240 -30
- package/src/harness-control-plane/finalize.ts +84 -0
- package/src/harness-control-plane/owner.ts +13 -0
- package/src/harness-control-plane/receipts.ts +39 -1
- package/src/harness-control-plane/types.ts +25 -1
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/modes/interactive-mode.ts +26 -0
- package/src/modes/rpc/rpc-client.ts +22 -0
- package/src/prompts/system/system-prompt.md +9 -0
- package/src/reminders/star-reminder.ts +422 -0
- package/src/session/agent-session.ts +79 -13
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub star reminder.
|
|
3
|
+
*
|
|
4
|
+
* On interactive launch, if `gh` is authenticated and the GJC repo is not
|
|
5
|
+
* starred, the user is nudged to star it. Declining switches to a per-session
|
|
6
|
+
* persuasion message injected at the before-agent-start point until the repo is
|
|
7
|
+
* starred. Detection is `gh`-only: if `gh` is missing, unauthenticated, offline,
|
|
8
|
+
* or fails for any non-404 reason, the feature stays completely silent.
|
|
9
|
+
*
|
|
10
|
+
* All state lives in a user-global file under the GJC config root and is updated
|
|
11
|
+
* under a file lock with atomic temp+rename writes and a monotonic merge that
|
|
12
|
+
* never lets a stale "declined"/"unstarred" write clobber a confirmed star.
|
|
13
|
+
*/
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import * as fs from "node:fs/promises";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import type { ImageContent, MessageAttribution } from "@gajae-code/ai";
|
|
18
|
+
import { getConfigRootDir, isEnoent } from "@gajae-code/utils";
|
|
19
|
+
import { withFileLock } from "../config/file-lock";
|
|
20
|
+
import type { CustomMessage } from "../session/messages";
|
|
21
|
+
|
|
22
|
+
export const STAR_REMINDER_REPO = "Yeachan-Heo/gajae-code";
|
|
23
|
+
export const STAR_REMINDER_CUSTOM_TYPE = "star-reminder";
|
|
24
|
+
export const STARRED_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
const GH_TIMEOUT_MS = 5_000;
|
|
27
|
+
|
|
28
|
+
export interface StarReminderState {
|
|
29
|
+
declined: boolean;
|
|
30
|
+
starred: boolean;
|
|
31
|
+
/** ISO-8601 timestamp of the last authoritative star check, or "" if never. */
|
|
32
|
+
starredCheckedAt: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type StarCheckStatus = "starred" | "unstarred" | "unavailable";
|
|
36
|
+
|
|
37
|
+
export interface GhResult {
|
|
38
|
+
exitCode: number;
|
|
39
|
+
stdout: string;
|
|
40
|
+
stderr: string;
|
|
41
|
+
timedOut?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type RunGh = (args: string[], options?: { timeoutMs?: number }) => Promise<GhResult>;
|
|
45
|
+
|
|
46
|
+
export interface StarReminderDeps {
|
|
47
|
+
statePath?: string;
|
|
48
|
+
now?: () => Date;
|
|
49
|
+
runGh?: RunGh;
|
|
50
|
+
sleep?: (ms: number) => Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --------------------------------------------------------------------------
|
|
54
|
+
// State path + defaults
|
|
55
|
+
// --------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
export function getStarReminderStatePath(): string {
|
|
58
|
+
return path.join(getConfigRootDir(), "star-reminder.json");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function defaultStarReminderState(): StarReminderState {
|
|
62
|
+
return { declined: false, starred: false, starredCheckedAt: "" };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveStatePath(deps?: StarReminderDeps): string {
|
|
66
|
+
return deps?.statePath ?? getStarReminderStatePath();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveNow(deps?: StarReminderDeps): Date {
|
|
70
|
+
return deps?.now ? deps.now() : new Date();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isValidState(value: unknown): value is StarReminderState {
|
|
74
|
+
if (!value || typeof value !== "object") return false;
|
|
75
|
+
const v = value as Record<string, unknown>;
|
|
76
|
+
return typeof v.declined === "boolean" && typeof v.starred === "boolean" && typeof v.starredCheckedAt === "string";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --------------------------------------------------------------------------
|
|
80
|
+
// State IO
|
|
81
|
+
// --------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/** Read state without locking. Missing or malformed files return the default. */
|
|
84
|
+
export async function readStarReminderStateUnlocked(statePath?: string): Promise<StarReminderState> {
|
|
85
|
+
const target = statePath ?? getStarReminderStatePath();
|
|
86
|
+
try {
|
|
87
|
+
const raw = await fs.readFile(target, "utf8");
|
|
88
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
89
|
+
if (!isValidState(parsed)) return defaultStarReminderState();
|
|
90
|
+
return { declined: parsed.declined, starred: parsed.starred, starredCheckedAt: parsed.starredCheckedAt };
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (isEnoent(err)) return defaultStarReminderState();
|
|
93
|
+
// Malformed JSON or any read error -> treat as default; never throw to UI.
|
|
94
|
+
return defaultStarReminderState();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether a stored `starred:true` is still within the 24h cache window. */
|
|
99
|
+
export function isStarredCacheFresh(state: StarReminderState, now: Date = new Date()): boolean {
|
|
100
|
+
if (!state.starred) return false;
|
|
101
|
+
const checked = new Date(state.starredCheckedAt).getTime();
|
|
102
|
+
if (Number.isNaN(checked)) return false;
|
|
103
|
+
const age = now.getTime() - checked;
|
|
104
|
+
return age >= 0 && age < STARRED_CACHE_TTL_MS;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function writeStateAtomic(statePath: string, state: StarReminderState): Promise<void> {
|
|
108
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true, mode: 0o700 });
|
|
109
|
+
const temp = `${statePath}.tmp.${process.pid}.${Date.now()}.${randomUUID()}`;
|
|
110
|
+
try {
|
|
111
|
+
await fs.writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
112
|
+
await fs.rename(temp, statePath);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
await fs.rm(temp, { force: true }).catch(() => {});
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Lock-protected read-modify-write. The mutator receives the freshly re-read
|
|
121
|
+
* state under the lock; callers MUST base monotonic decisions on that value,
|
|
122
|
+
* not on any snapshot captured before the lock was held.
|
|
123
|
+
*/
|
|
124
|
+
export async function updateStarReminderStateLocked(
|
|
125
|
+
mutator: (current: StarReminderState) => StarReminderState | Promise<StarReminderState>,
|
|
126
|
+
deps?: StarReminderDeps,
|
|
127
|
+
): Promise<StarReminderState> {
|
|
128
|
+
const statePath = resolveStatePath(deps);
|
|
129
|
+
// The lock file lives next to the state file, so its parent dir must exist
|
|
130
|
+
// before withFileLock tries to create the (non-recursive) lock directory.
|
|
131
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true, mode: 0o700 });
|
|
132
|
+
return withFileLock(statePath, async () => {
|
|
133
|
+
const current = await readStarReminderStateUnlocked(statePath);
|
|
134
|
+
const next = await mutator(current);
|
|
135
|
+
await writeStateAtomic(statePath, next);
|
|
136
|
+
return next;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --------------------------------------------------------------------------
|
|
141
|
+
// Monotonic merge helpers
|
|
142
|
+
// --------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/** A successful PUT is authoritative: always record starred and clear declined. */
|
|
145
|
+
export async function recordStarredFromPut(deps?: StarReminderDeps): Promise<StarReminderState> {
|
|
146
|
+
const checkedAt = resolveNow(deps).toISOString();
|
|
147
|
+
return updateStarReminderStateLocked(() => ({ declined: false, starred: true, starredCheckedAt: checkedAt }), deps);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Record the result of a fresh `gh` star check performed by this operation.
|
|
152
|
+
* - "starred": authoritative, clears declined so all reminders stop.
|
|
153
|
+
* - "unstarred": may downgrade, but only when the current state is not a
|
|
154
|
+
* still-fresh confirmed star (which a concurrent process may have just
|
|
155
|
+
* written); in that case the fresher confirmation wins.
|
|
156
|
+
*/
|
|
157
|
+
export async function recordFreshStarCheck(
|
|
158
|
+
status: "starred" | "unstarred",
|
|
159
|
+
deps?: StarReminderDeps,
|
|
160
|
+
): Promise<StarReminderState> {
|
|
161
|
+
const now = resolveNow(deps);
|
|
162
|
+
const checkedAt = now.toISOString();
|
|
163
|
+
return updateStarReminderStateLocked(current => {
|
|
164
|
+
if (status === "starred") {
|
|
165
|
+
return { declined: false, starred: true, starredCheckedAt: checkedAt };
|
|
166
|
+
}
|
|
167
|
+
// Preserve a confirmed star that is either still within the cache window
|
|
168
|
+
// or was written concurrently AFTER this operation's check time. The latter
|
|
169
|
+
// guards against a stale unstarred observation clobbering a newer star that
|
|
170
|
+
// another process recorded while we were waiting on the lock.
|
|
171
|
+
if (current.starred) {
|
|
172
|
+
const currentChecked = new Date(current.starredCheckedAt).getTime();
|
|
173
|
+
const newerThanThisCheck = !Number.isNaN(currentChecked) && currentChecked > now.getTime();
|
|
174
|
+
if (newerThanThisCheck || isStarredCacheFresh(current, now)) {
|
|
175
|
+
return current;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { declined: current.declined, starred: false, starredCheckedAt: checkedAt };
|
|
179
|
+
}, deps);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Record a launch-nudge decline. Never downgrades a confirmed star. */
|
|
183
|
+
export async function recordDeclinedAfterNo(deps?: StarReminderDeps): Promise<StarReminderState> {
|
|
184
|
+
return updateStarReminderStateLocked(current => {
|
|
185
|
+
if (current.starred) return current;
|
|
186
|
+
return { ...current, declined: true };
|
|
187
|
+
}, deps);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// --------------------------------------------------------------------------
|
|
191
|
+
// gh helpers
|
|
192
|
+
// --------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
/** Default `gh` runner. Returns an unavailable-style result instead of throwing. */
|
|
195
|
+
export async function runGhDefault(args: string[], options?: { timeoutMs?: number }): Promise<GhResult> {
|
|
196
|
+
const ghPath = Bun.which("gh");
|
|
197
|
+
if (!ghPath) {
|
|
198
|
+
return { exitCode: -1, stdout: "", stderr: "gh not found", timedOut: false };
|
|
199
|
+
}
|
|
200
|
+
const timeoutMs = options?.timeoutMs ?? GH_TIMEOUT_MS;
|
|
201
|
+
let timedOut = false;
|
|
202
|
+
try {
|
|
203
|
+
const proc = Bun.spawn([ghPath, ...args], { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
|
|
204
|
+
const timer = setTimeout(() => {
|
|
205
|
+
timedOut = true;
|
|
206
|
+
proc.kill();
|
|
207
|
+
}, timeoutMs);
|
|
208
|
+
try {
|
|
209
|
+
const [stdout, stderr] = await Promise.all([
|
|
210
|
+
new Response(proc.stdout).text(),
|
|
211
|
+
new Response(proc.stderr).text(),
|
|
212
|
+
]);
|
|
213
|
+
const exitCode = await proc.exited;
|
|
214
|
+
return { exitCode, stdout, stderr, timedOut };
|
|
215
|
+
} finally {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
}
|
|
218
|
+
} catch (err) {
|
|
219
|
+
return { exitCode: -1, stdout: "", stderr: err instanceof Error ? err.message : String(err), timedOut };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function resolveRunGh(deps?: StarReminderDeps): RunGh {
|
|
224
|
+
return deps?.runGh ?? runGhDefault;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Classify the star state of the repo via `gh api`. */
|
|
228
|
+
export async function checkGhStarred(deps?: StarReminderDeps): Promise<StarCheckStatus> {
|
|
229
|
+
const runGh = resolveRunGh(deps);
|
|
230
|
+
const res = await runGh(["api", `user/starred/${STAR_REMINDER_REPO}`], { timeoutMs: GH_TIMEOUT_MS });
|
|
231
|
+
if (res.timedOut) return "unavailable";
|
|
232
|
+
if (res.exitCode === 0) return "starred";
|
|
233
|
+
// A genuine 404 from this endpoint is the unambiguous "not starred" signal.
|
|
234
|
+
// gh emits "gh: Not Found (HTTP 404)" for that case. Other failures (missing
|
|
235
|
+
// gh, auth, network, malformed output) must stay silent, so we key strictly
|
|
236
|
+
// on the explicit HTTP 404 status rather than any "404"/"not found" substring,
|
|
237
|
+
// which could appear in unrelated error text.
|
|
238
|
+
if (/http[\s/]?404\b/i.test(res.stderr)) return "unstarred";
|
|
239
|
+
return "unavailable";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Star the repo via `gh api -X PUT`. Returns whether the PUT succeeded. */
|
|
243
|
+
export async function autoStarRepo(deps?: StarReminderDeps): Promise<boolean> {
|
|
244
|
+
const runGh = resolveRunGh(deps);
|
|
245
|
+
const res = await runGh(["api", "-X", "PUT", `user/starred/${STAR_REMINDER_REPO}`], { timeoutMs: GH_TIMEOUT_MS });
|
|
246
|
+
return res.exitCode === 0 && !res.timedOut;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Determine the star state for this session, hitting `gh` only when needed.
|
|
251
|
+
* A fresh cached star skips `gh`; unstarred and declined states are rechecked.
|
|
252
|
+
* Authoritative results are persisted via the monotonic helpers.
|
|
253
|
+
*/
|
|
254
|
+
export async function refreshStarStateForSession(deps?: StarReminderDeps): Promise<StarCheckStatus> {
|
|
255
|
+
const statePath = resolveStatePath(deps);
|
|
256
|
+
const state = await readStarReminderStateUnlocked(statePath);
|
|
257
|
+
if (state.starred && isStarredCacheFresh(state, resolveNow(deps))) {
|
|
258
|
+
return "starred";
|
|
259
|
+
}
|
|
260
|
+
const status = await checkGhStarred(deps);
|
|
261
|
+
if (status === "starred") {
|
|
262
|
+
await recordFreshStarCheck("starred", deps);
|
|
263
|
+
} else if (status === "unstarred") {
|
|
264
|
+
await recordFreshStarCheck("unstarred", deps);
|
|
265
|
+
}
|
|
266
|
+
return status;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --------------------------------------------------------------------------
|
|
270
|
+
// Launch nudge
|
|
271
|
+
// --------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
export interface StarReminderPromptUI {
|
|
274
|
+
/** Show a yes/no confirmation. Resolves true when the user accepts. */
|
|
275
|
+
confirm(title: string, message: string): Promise<boolean>;
|
|
276
|
+
/** Optional guard; when it returns false the nudge is skipped silently. */
|
|
277
|
+
isIdle?: () => boolean;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const LAUNCH_PROMPT_TITLE = "Enjoying GJC?";
|
|
281
|
+
const LAUNCH_PROMPT_MESSAGE = `Star ${STAR_REMINDER_REPO} on GitHub to support the project?`;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Run the launch nudge once. Caller is responsible for the `startup.quiet`,
|
|
285
|
+
* `starReminder.enabled`, and true-interactive gates. All errors are swallowed
|
|
286
|
+
* so the launch path can never be broken by the reminder.
|
|
287
|
+
*/
|
|
288
|
+
export async function maybeShowLaunchStarReminder(ui: StarReminderPromptUI, deps?: StarReminderDeps): Promise<void> {
|
|
289
|
+
try {
|
|
290
|
+
const statePath = resolveStatePath(deps);
|
|
291
|
+
const state = await readStarReminderStateUnlocked(statePath);
|
|
292
|
+
// Declined users no longer see the launch prompt; the injection path
|
|
293
|
+
// handles re-checks for them.
|
|
294
|
+
if (state.declined) return;
|
|
295
|
+
if (state.starred && isStarredCacheFresh(state, resolveNow(deps))) return;
|
|
296
|
+
|
|
297
|
+
const status = await checkGhStarred(deps);
|
|
298
|
+
if (status === "starred") {
|
|
299
|
+
await recordFreshStarCheck("starred", deps);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (status === "unavailable") return;
|
|
303
|
+
|
|
304
|
+
// status === "unstarred". Persist this fresh unstarred evidence first so a
|
|
305
|
+
// stale cached starred:true is downgraded and a subsequent No is recorded as
|
|
306
|
+
// declined (recordDeclinedAfterNo only preserves a still-starred state).
|
|
307
|
+
await recordFreshStarCheck("unstarred", deps);
|
|
308
|
+
if (ui.isIdle && !ui.isIdle()) return;
|
|
309
|
+
const accepted = await ui.confirm(LAUNCH_PROMPT_TITLE, LAUNCH_PROMPT_MESSAGE);
|
|
310
|
+
if (accepted) {
|
|
311
|
+
const ok = await autoStarRepo(deps);
|
|
312
|
+
if (ok) await recordStarredFromPut(deps);
|
|
313
|
+
// On PUT failure: stay silent and do not mark starred; a later launch
|
|
314
|
+
// may prompt again while still not declined.
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
await recordDeclinedAfterNo(deps);
|
|
318
|
+
} catch {
|
|
319
|
+
// Never surface star-reminder failures to the user.
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Schedule the launch nudge to run after the first render so the networked
|
|
325
|
+
* `gh` check never blocks startup. Returns immediately.
|
|
326
|
+
*/
|
|
327
|
+
export function scheduleLaunchStarReminderAfterFirstRender(ui: StarReminderPromptUI, deps?: StarReminderDeps): void {
|
|
328
|
+
setTimeout(() => {
|
|
329
|
+
void maybeShowLaunchStarReminder(ui, deps);
|
|
330
|
+
}, 0);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export interface StarReminderLaunchGateInput {
|
|
334
|
+
/** The `starReminder.enabled` setting. */
|
|
335
|
+
enabled: boolean;
|
|
336
|
+
/** The `startup.quiet` setting. */
|
|
337
|
+
quiet: boolean;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export interface StarReminderLaunchGate {
|
|
341
|
+
/** Whether to register the decline-driven injection contributor. */
|
|
342
|
+
register: boolean;
|
|
343
|
+
/** Whether to schedule the launch nudge after first render. */
|
|
344
|
+
schedule: boolean;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Pure decision for interactive wiring. The injection contributor is registered
|
|
349
|
+
* whenever the feature is enabled; the launch nudge is additionally suppressed
|
|
350
|
+
* by quiet startup. Centralizing this keeps the interactive gate testable.
|
|
351
|
+
*/
|
|
352
|
+
export function starReminderLaunchGate(input: StarReminderLaunchGateInput): StarReminderLaunchGate {
|
|
353
|
+
return { register: input.enabled, schedule: input.enabled && !input.quiet };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// --------------------------------------------------------------------------
|
|
357
|
+
// Decline-driven injection
|
|
358
|
+
// --------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
export interface StarReminderSessionRef {
|
|
361
|
+
getSessionId(): string | undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export type StarReminderCustomMessage = Pick<
|
|
365
|
+
CustomMessage,
|
|
366
|
+
"customType" | "content" | "display" | "details" | "attribution"
|
|
367
|
+
>;
|
|
368
|
+
|
|
369
|
+
export type InternalBeforeAgentStartContributor = (event: {
|
|
370
|
+
prompt: string;
|
|
371
|
+
images?: ImageContent[];
|
|
372
|
+
sessionId: string | undefined;
|
|
373
|
+
}) => Promise<StarReminderCustomMessage | undefined>;
|
|
374
|
+
|
|
375
|
+
const PERSUASION_CONTENT =
|
|
376
|
+
`The user previously declined the launch GitHub-star prompt. At an appropriate moment in this response, ` +
|
|
377
|
+
`briefly and politely encourage starring https://github.com/${STAR_REMINDER_REPO} to support the project. ` +
|
|
378
|
+
`Ask at most once and do not be pushy.`;
|
|
379
|
+
|
|
380
|
+
export function createStarReminderMessage(): StarReminderCustomMessage {
|
|
381
|
+
const attribution: MessageAttribution = "agent";
|
|
382
|
+
return {
|
|
383
|
+
customType: STAR_REMINDER_CUSTOM_TYPE,
|
|
384
|
+
content: PERSUASION_CONTENT,
|
|
385
|
+
display: false,
|
|
386
|
+
attribution,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Build a before-agent-start contributor that injects the persuasion message
|
|
392
|
+
* once per logical session id, for declined-and-still-unstarred users only.
|
|
393
|
+
*/
|
|
394
|
+
export function createStarReminderBeforeAgentStartContributor(
|
|
395
|
+
session: StarReminderSessionRef,
|
|
396
|
+
deps?: StarReminderDeps,
|
|
397
|
+
): InternalBeforeAgentStartContributor {
|
|
398
|
+
const injectedSessionIds = new Set<string>();
|
|
399
|
+
return async event => {
|
|
400
|
+
try {
|
|
401
|
+
const state = await readStarReminderStateUnlocked(resolveStatePath(deps));
|
|
402
|
+
if (!state.declined) return undefined;
|
|
403
|
+
|
|
404
|
+
// Without a stable logical session id we cannot enforce once-per-session,
|
|
405
|
+
// so prefer not injecting at all.
|
|
406
|
+
const sessionId = session.getSessionId() ?? event.sessionId;
|
|
407
|
+
if (!sessionId) return undefined;
|
|
408
|
+
if (injectedSessionIds.has(sessionId)) return undefined;
|
|
409
|
+
|
|
410
|
+
// Process each logical session at most once, regardless of outcome:
|
|
411
|
+
// even when gh is unavailable we must not re-check (and possibly delay)
|
|
412
|
+
// on every subsequent prompt in the same session.
|
|
413
|
+
injectedSessionIds.add(sessionId);
|
|
414
|
+
|
|
415
|
+
const status = await refreshStarStateForSession(deps);
|
|
416
|
+
if (status !== "unstarred") return undefined;
|
|
417
|
+
return createStarReminderMessage();
|
|
418
|
+
} catch {
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
}
|
|
@@ -822,6 +822,23 @@ function extractPermissionLocations(
|
|
|
822
822
|
* rely on the existing text-equality match. */
|
|
823
823
|
type QueuedDisplayEntry = { text: string; tag?: string };
|
|
824
824
|
|
|
825
|
+
/** A custom message contributed at the before-agent-start point. */
|
|
826
|
+
export type BeforeAgentStartInternalMessage = Pick<
|
|
827
|
+
CustomMessage,
|
|
828
|
+
"customType" | "content" | "display" | "details" | "attribution"
|
|
829
|
+
>;
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Internal (first-party, non-user-hook) contributor invoked at the active
|
|
833
|
+
* before-agent-start point alongside the extension runner. Returns an optional
|
|
834
|
+
* custom message to append to the prompt context. Errors are nonfatal.
|
|
835
|
+
*/
|
|
836
|
+
export type BeforeAgentStartContributor = (event: {
|
|
837
|
+
prompt: string;
|
|
838
|
+
images?: ImageContent[];
|
|
839
|
+
sessionId: string | undefined;
|
|
840
|
+
}) => Promise<BeforeAgentStartInternalMessage | undefined>;
|
|
841
|
+
|
|
825
842
|
export class AgentSession {
|
|
826
843
|
readonly agent: Agent;
|
|
827
844
|
readonly sessionManager: SessionManager;
|
|
@@ -922,6 +939,8 @@ export class AgentSession {
|
|
|
922
939
|
// Extension system
|
|
923
940
|
#extensionRunner: ExtensionRunner | undefined = undefined;
|
|
924
941
|
#turnIndex = 0;
|
|
942
|
+
// First-party internal before-agent-start contributors (not user hooks).
|
|
943
|
+
#beforeAgentStartContributors: BeforeAgentStartContributor[] = [];
|
|
925
944
|
|
|
926
945
|
#skills: Skill[];
|
|
927
946
|
#skillWarnings: SkillWarning[];
|
|
@@ -4756,6 +4775,9 @@ export class AgentSession {
|
|
|
4756
4775
|
|
|
4757
4776
|
const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText);
|
|
4758
4777
|
|
|
4778
|
+
const promptAttribution: "user" | "agent" | undefined =
|
|
4779
|
+
"attribution" in message ? message.attribution : undefined;
|
|
4780
|
+
|
|
4759
4781
|
// Emit before_agent_start extension event
|
|
4760
4782
|
if (this.#extensionRunner) {
|
|
4761
4783
|
const result = await this.#extensionRunner.emitBeforeAgentStart(
|
|
@@ -4764,19 +4786,7 @@ export class AgentSession {
|
|
|
4764
4786
|
beforeAgentStartSystemPrompt,
|
|
4765
4787
|
);
|
|
4766
4788
|
if (result?.messages) {
|
|
4767
|
-
|
|
4768
|
-
"attribution" in message ? message.attribution : undefined;
|
|
4769
|
-
for (const msg of result.messages) {
|
|
4770
|
-
messages.push({
|
|
4771
|
-
role: "custom",
|
|
4772
|
-
customType: msg.customType,
|
|
4773
|
-
content: msg.content,
|
|
4774
|
-
display: msg.display,
|
|
4775
|
-
details: msg.details,
|
|
4776
|
-
attribution: msg.attribution ?? promptAttribution ?? (message.role === "user" ? "user" : "agent"),
|
|
4777
|
-
timestamp: Date.now(),
|
|
4778
|
-
});
|
|
4779
|
-
}
|
|
4789
|
+
this.#appendBeforeAgentStartCustomMessages(messages, result.messages, promptAttribution, message.role);
|
|
4780
4790
|
}
|
|
4781
4791
|
|
|
4782
4792
|
if (result?.systemPrompt !== undefined) {
|
|
@@ -4788,6 +4798,26 @@ export class AgentSession {
|
|
|
4788
4798
|
this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
|
|
4789
4799
|
}
|
|
4790
4800
|
|
|
4801
|
+
// Invoke first-party internal before-agent-start contributors. These run
|
|
4802
|
+
// alongside the extension runner (not via user-loaded hooks) and append
|
|
4803
|
+
// through the same custom-message attribution path. Errors are nonfatal.
|
|
4804
|
+
if (this.#beforeAgentStartContributors.length > 0) {
|
|
4805
|
+
const contributed: BeforeAgentStartInternalMessage[] = [];
|
|
4806
|
+
for (const contributor of this.#beforeAgentStartContributors) {
|
|
4807
|
+
try {
|
|
4808
|
+
const msg = await contributor({
|
|
4809
|
+
prompt: expandedText,
|
|
4810
|
+
images: options?.images,
|
|
4811
|
+
sessionId: this.sessionId,
|
|
4812
|
+
});
|
|
4813
|
+
if (msg) contributed.push(msg);
|
|
4814
|
+
} catch (err) {
|
|
4815
|
+
logger.debug("before_agent_start contributor failed", { error: String(err) });
|
|
4816
|
+
}
|
|
4817
|
+
}
|
|
4818
|
+
this.#appendBeforeAgentStartCustomMessages(messages, contributed, promptAttribution, message.role);
|
|
4819
|
+
}
|
|
4820
|
+
|
|
4791
4821
|
// Bail out if a newer abort/prompt cycle has started since we began setup
|
|
4792
4822
|
if (this.#promptGeneration !== generation) {
|
|
4793
4823
|
return;
|
|
@@ -9622,6 +9652,42 @@ export class AgentSession {
|
|
|
9622
9652
|
return this.#extensionRunner?.hasHandlers(eventType) ?? false;
|
|
9623
9653
|
}
|
|
9624
9654
|
|
|
9655
|
+
/**
|
|
9656
|
+
* Register a first-party internal before-agent-start contributor. Returns an
|
|
9657
|
+
* unregister function. This is NOT user-facing hook discovery; it is an
|
|
9658
|
+
* in-core seam invoked alongside the extension runner.
|
|
9659
|
+
*/
|
|
9660
|
+
registerBeforeAgentStartContributor(contributor: BeforeAgentStartContributor): () => void {
|
|
9661
|
+
this.#beforeAgentStartContributors.push(contributor);
|
|
9662
|
+
return () => {
|
|
9663
|
+
const idx = this.#beforeAgentStartContributors.indexOf(contributor);
|
|
9664
|
+
if (idx !== -1) this.#beforeAgentStartContributors.splice(idx, 1);
|
|
9665
|
+
};
|
|
9666
|
+
}
|
|
9667
|
+
|
|
9668
|
+
/**
|
|
9669
|
+
* Append before-agent-start custom messages (from the extension runner or
|
|
9670
|
+
* internal contributors) using one shared attribution/defaulting path.
|
|
9671
|
+
*/
|
|
9672
|
+
#appendBeforeAgentStartCustomMessages(
|
|
9673
|
+
target: AgentMessage[],
|
|
9674
|
+
returned: readonly BeforeAgentStartInternalMessage[],
|
|
9675
|
+
promptAttribution: "user" | "agent" | undefined,
|
|
9676
|
+
messageRole: string,
|
|
9677
|
+
): void {
|
|
9678
|
+
for (const msg of returned) {
|
|
9679
|
+
target.push({
|
|
9680
|
+
role: "custom",
|
|
9681
|
+
customType: msg.customType,
|
|
9682
|
+
content: msg.content,
|
|
9683
|
+
display: msg.display,
|
|
9684
|
+
details: msg.details,
|
|
9685
|
+
attribution: msg.attribution ?? promptAttribution ?? (messageRole === "user" ? "user" : "agent"),
|
|
9686
|
+
timestamp: Date.now(),
|
|
9687
|
+
});
|
|
9688
|
+
}
|
|
9689
|
+
}
|
|
9690
|
+
|
|
9625
9691
|
/**
|
|
9626
9692
|
* Get the extension runner (for setting UI context and error handlers).
|
|
9627
9693
|
*/
|