@gajae-code/coding-agent 0.7.2 → 0.7.4

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.
Files changed (154) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/bin/gjc.js +4 -0
  3. package/dist/types/cli/mcp-cli.d.ts +25 -0
  4. package/dist/types/cli/plugin-cli.d.ts +2 -0
  5. package/dist/types/cli.d.ts +6 -0
  6. package/dist/types/commands/mcp.d.ts +70 -0
  7. package/dist/types/commands/plugin.d.ts +6 -0
  8. package/dist/types/commands/session.d.ts +6 -0
  9. package/dist/types/config/keybindings.d.ts +2 -2
  10. package/dist/types/config/model-profile-activation.d.ts +8 -1
  11. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  12. package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
  13. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
  14. package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
  15. package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
  16. package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
  17. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
  18. package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
  19. package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
  20. package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
  21. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
  22. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
  23. package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
  24. package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
  25. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  26. package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
  27. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
  28. package/dist/types/gjc-runtime/tmux-common.d.ts +20 -1
  29. package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
  30. package/dist/types/main.d.ts +2 -0
  31. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  32. package/dist/types/modes/components/model-selector.d.ts +8 -0
  33. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  34. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  35. package/dist/types/notifications/html-format.d.ts +11 -0
  36. package/dist/types/notifications/index.d.ts +149 -1
  37. package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
  38. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  39. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  40. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  41. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  42. package/dist/types/notifications/recent-activity.d.ts +35 -0
  43. package/dist/types/notifications/telegram-daemon.d.ts +114 -16
  44. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  45. package/dist/types/notifications/topic-registry.d.ts +12 -9
  46. package/dist/types/runtime-mcp/types.d.ts +7 -0
  47. package/dist/types/sdk.d.ts +2 -0
  48. package/dist/types/session/agent-session.d.ts +14 -4
  49. package/dist/types/session/blob-store.d.ts +25 -0
  50. package/dist/types/session/session-manager.d.ts +57 -0
  51. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  52. package/dist/types/system-prompt.d.ts +2 -0
  53. package/dist/types/task/executor.d.ts +9 -1
  54. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  55. package/dist/types/tools/index.d.ts +3 -1
  56. package/dist/types/utils/changelog.d.ts +1 -0
  57. package/dist/types/web/insane/url-guard.d.ts +6 -3
  58. package/dist/types/web/scrapers/types.d.ts +5 -0
  59. package/dist/types/web/scrapers/utils.d.ts +7 -1
  60. package/package.json +11 -9
  61. package/scripts/g004-tmux-smoke.ts +100 -0
  62. package/scripts/g005-daemon-smoke.ts +181 -0
  63. package/scripts/g011-daemon-path-smoke.ts +153 -0
  64. package/src/cli/mcp-cli.ts +272 -0
  65. package/src/cli/plugin-cli.ts +66 -3
  66. package/src/cli.ts +27 -6
  67. package/src/commands/mcp.ts +117 -0
  68. package/src/commands/plugin.ts +4 -0
  69. package/src/commands/session.ts +18 -0
  70. package/src/config/keybindings.ts +2 -2
  71. package/src/config/model-profile-activation.ts +55 -7
  72. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  73. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +7 -6
  75. package/src/defaults/gjc/skills/team/SKILL.md +5 -3
  76. package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
  77. package/src/export/html/index.ts +2 -2
  78. package/src/extensibility/extensions/runner.ts +1 -0
  79. package/src/extensibility/gjc-plugins/compiler.ts +351 -0
  80. package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
  81. package/src/extensibility/gjc-plugins/index.ts +9 -0
  82. package/src/extensibility/gjc-plugins/injection.ts +109 -0
  83. package/src/extensibility/gjc-plugins/installer.ts +434 -0
  84. package/src/extensibility/gjc-plugins/loader.ts +3 -1
  85. package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
  86. package/src/extensibility/gjc-plugins/observability.ts +84 -0
  87. package/src/extensibility/gjc-plugins/paths.ts +1 -1
  88. package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
  89. package/src/extensibility/gjc-plugins/registry.ts +180 -0
  90. package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
  91. package/src/extensibility/gjc-plugins/schema.ts +250 -20
  92. package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
  93. package/src/extensibility/gjc-plugins/types.ts +199 -3
  94. package/src/extensibility/gjc-plugins/validation.ts +80 -0
  95. package/src/extensibility/skills.ts +15 -0
  96. package/src/gjc-runtime/launch-tmux.ts +61 -7
  97. package/src/gjc-runtime/psmux-detect.ts +239 -0
  98. package/src/gjc-runtime/team-runtime.ts +56 -23
  99. package/src/gjc-runtime/tmux-common.ts +30 -3
  100. package/src/gjc-runtime/tmux-sessions.ts +51 -1
  101. package/src/gjc-runtime/ultragoal-guard.ts +25 -8
  102. package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
  103. package/src/hooks/skill-state.ts +57 -0
  104. package/src/internal-urls/docs-index.generated.ts +12 -8
  105. package/src/main.ts +14 -3
  106. package/src/modes/bridge/bridge-mode.ts +11 -0
  107. package/src/modes/components/custom-editor.ts +2 -0
  108. package/src/modes/components/footer.ts +2 -3
  109. package/src/modes/components/hook-editor.ts +1 -1
  110. package/src/modes/components/hook-selector.ts +67 -43
  111. package/src/modes/components/model-selector.ts +56 -11
  112. package/src/modes/components/status-line/git-utils.ts +25 -0
  113. package/src/modes/components/status-line.ts +10 -11
  114. package/src/modes/components/welcome.ts +2 -3
  115. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  116. package/src/modes/controllers/selector-controller.ts +53 -11
  117. package/src/modes/interactive-mode.ts +4 -1
  118. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  119. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  120. package/src/modes/theme/defaults/index.ts +2 -0
  121. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  122. package/src/notifications/html-format.ts +38 -0
  123. package/src/notifications/index.ts +242 -12
  124. package/src/notifications/lifecycle-commands.ts +228 -0
  125. package/src/notifications/lifecycle-control-runtime.ts +400 -0
  126. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  127. package/src/notifications/operator-runtime.ts +171 -0
  128. package/src/notifications/rate-limit-pool.ts +19 -0
  129. package/src/notifications/recent-activity.ts +132 -0
  130. package/src/notifications/telegram-daemon.ts +778 -257
  131. package/src/notifications/telegram-reference.ts +25 -7
  132. package/src/notifications/topic-registry.ts +23 -9
  133. package/src/prompts/agents/executor.md +2 -2
  134. package/src/runtime-mcp/transports/stdio.ts +38 -4
  135. package/src/runtime-mcp/types.ts +7 -0
  136. package/src/sdk.ts +157 -10
  137. package/src/session/agent-session.ts +166 -74
  138. package/src/session/blob-store.ts +196 -8
  139. package/src/session/session-manager.ts +678 -7
  140. package/src/slash-commands/builtin-registry.ts +23 -3
  141. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  142. package/src/slash-commands/helpers/parse.ts +2 -1
  143. package/src/system-prompt.ts +9 -0
  144. package/src/task/executor.ts +31 -7
  145. package/src/task/index.ts +2 -0
  146. package/src/tools/ask.ts +5 -1
  147. package/src/tools/bash.ts +9 -0
  148. package/src/tools/composer-bash-policy.ts +96 -0
  149. package/src/tools/fetch.ts +18 -2
  150. package/src/tools/index.ts +3 -1
  151. package/src/utils/changelog.ts +8 -0
  152. package/src/web/insane/url-guard.ts +18 -14
  153. package/src/web/scrapers/types.ts +143 -45
  154. package/src/web/scrapers/utils.ts +70 -19
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Telegram session-lifecycle orchestrator (G005 core).
3
+ *
4
+ * Owns the daemon-side policy for remote session create / close / resume:
5
+ * strict paired-chat gating, a durable + atomic idempotency state machine,
6
+ * per-chat create rate limiting, audit logging with token/prompt redaction, and
7
+ * dispatch to injected effects (spawn / close / resume). It is deliberately
8
+ * effect-injected so the decision logic is unit-testable and the same code path
9
+ * is exercised end-to-end by a real-tmux integration smoke.
10
+ *
11
+ * The Rust control ingress (crates/gjc-notifications control server) has already
12
+ * authenticated frames before they reach here; this module never sees or logs
13
+ * the raw control token.
14
+ */
15
+ import * as crypto from "node:crypto";
16
+
17
+ import type { LifecycleErrorReason, ResumeCandidate, SessionCreateFrame, SessionLifecycleRequest } from "./index";
18
+
19
+ /** Durable idempotency state for a single lifecycle request. */
20
+ export type LedgerState = "in_progress" | "success" | "failure" | "terminal_uncertain";
21
+
22
+ /** One persisted idempotency entry, keyed by `chatId:updateId`. */
23
+ export interface LedgerEntry {
24
+ requestHash: string;
25
+ state: LedgerState;
26
+ requestId: string;
27
+ verb: SessionLifecycleRequest["type"];
28
+ intendedSessionId?: string;
29
+ startupPromptRef?: string;
30
+ createdAt: number;
31
+ updatedAt: number;
32
+ targetSummary: Record<string, unknown>;
33
+ sessionId?: string;
34
+ tmuxSession?: string;
35
+ sessionStateFile?: string;
36
+ endpointUrl?: string;
37
+ /** Close effect outcome: whether the tmux process is confirmed gone. */
38
+ processGone?: boolean;
39
+ reason?: LifecycleErrorReason;
40
+ }
41
+
42
+ /** The full on-disk ledger document. */
43
+ export interface LedgerDoc {
44
+ version: 1;
45
+ entries: Record<string, LedgerEntry>;
46
+ }
47
+
48
+ /** Persistence boundary: atomic + fsynced read/write of the ledger document. */
49
+ export interface LedgerStore {
50
+ read(): Promise<LedgerDoc>;
51
+ /** Write atomically (temp + fsync + rename) under a per-ledger lock. */
52
+ write(doc: LedgerDoc): Promise<void>;
53
+ }
54
+
55
+ /** One audit line. Tokens and raw prompts are NEVER included. */
56
+ export interface AuditEvent {
57
+ ts: string;
58
+ event:
59
+ | "accepted"
60
+ | "rejected"
61
+ | "duplicate_reack"
62
+ | "rate_limited"
63
+ | "spawn_started"
64
+ | "recovered_in_progress"
65
+ | "success"
66
+ | "failure"
67
+ | "terminal_uncertain";
68
+ chatId: string;
69
+ updateId: number;
70
+ requestId: string;
71
+ requestHash: string;
72
+ verb: SessionLifecycleRequest["type"];
73
+ targetSummary: Record<string, unknown>;
74
+ sessionId?: string;
75
+ tmuxSession?: string;
76
+ reason?: LifecycleErrorReason;
77
+ /** Prompt byte length only (never the prompt text). */
78
+ promptBytes?: number;
79
+ /** Prompt content hash only (never the prompt text). */
80
+ promptHash?: string;
81
+ }
82
+
83
+ export interface CreateEffectResult {
84
+ sessionId: string;
85
+ tmuxSession: string;
86
+ sessionStateFile?: string;
87
+ endpointUrl: string;
88
+ topicThreadId: string;
89
+ }
90
+
91
+ export interface ResumeEffectResult extends CreateEffectResult {
92
+ mode: "reattached" | "cold_restarted";
93
+ }
94
+
95
+ /** Injected effects + policy. Pure orchestration calls into these. */
96
+ export interface OrchestratorDeps {
97
+ /** The single paired chat id. Anything else is rejected before parsing. */
98
+ pairedChatId: string;
99
+ now: () => number;
100
+ store: LedgerStore;
101
+ audit: (event: AuditEvent) => Promise<void> | void;
102
+ /** Per-chat create rate limiter: returns true when allowed. */
103
+ allowCreate: (chatId: string, nowMs: number) => boolean;
104
+ /** Persist the once-consumed 0600 startup-prompt file; returns its ref. */
105
+ writeStartupPrompt: (requestId: string, prompt: string | undefined) => Promise<string | undefined>;
106
+ /** Spawn a session for a create/cold-restart. */
107
+ spawnCreate: (
108
+ frame: SessionCreateFrame,
109
+ ids: { lifecycleRequestId: string; intendedSessionId: string; startupPromptRef?: string },
110
+ ) => Promise<CreateEffectResult>;
111
+ closeSession: (target: {
112
+ sessionId: string;
113
+ tmuxSession?: string;
114
+ sessionStateFile?: string;
115
+ }) => Promise<{ processGone: boolean }>;
116
+ resumeSession: (target: {
117
+ sessionIdOrPrefix: string;
118
+ path?: string;
119
+ }) => Promise<ResumeEffectResult | { ambiguous: ResumeCandidate[] } | { notFound: true }>;
120
+ newLifecycleRequestId: () => string;
121
+ newSessionId: () => string;
122
+ }
123
+
124
+ /** A redaction-safe summary of a request target (never includes the token). */
125
+ export function summarizeTarget(frame: SessionLifecycleRequest): Record<string, unknown> {
126
+ switch (frame.type) {
127
+ case "session_create":
128
+ return frame.target.kind === "worktree"
129
+ ? { kind: "worktree", repo: frame.target.repo, branch: frame.target.branch }
130
+ : { kind: frame.target.kind, path: frame.target.path };
131
+ case "session_close":
132
+ return { sessionId: frame.target.sessionId };
133
+ case "session_resume":
134
+ return { sessionIdOrPrefix: frame.target.sessionIdOrPrefix };
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Stable request hash over the meaningful (non-token) request content. Used to
140
+ * detect a duplicate update id reused with a DIFFERENT body (conflict).
141
+ */
142
+ export function requestHash(frame: SessionLifecycleRequest): string {
143
+ const canonical = JSON.stringify({
144
+ type: frame.type,
145
+ target: summarizeTarget(frame),
146
+ startupPromptRef: "startupPromptRef" in frame ? frame.startupPromptRef : undefined,
147
+ force: frame.type === "session_close" ? frame.force === true : undefined,
148
+ });
149
+ return crypto.createHash("sha256").update(canonical).digest("hex");
150
+ }
151
+
152
+ export function ledgerKey(chatId: string, updateId: number): string {
153
+ return `${chatId}:${updateId}`;
154
+ }
155
+
156
+ /** How a freshly-arrived request relates to the durable ledger. */
157
+ export type DuplicateClass =
158
+ | { kind: "new" }
159
+ | { kind: "reack_success"; entry: LedgerEntry }
160
+ | { kind: "reack_failure"; entry: LedgerEntry }
161
+ | { kind: "in_progress"; entry: LedgerEntry }
162
+ | { kind: "terminal_uncertain"; entry: LedgerEntry }
163
+ | { kind: "conflict"; entry: LedgerEntry };
164
+
165
+ /** Classify a request against an existing ledger entry (pure). */
166
+ export function classifyDuplicate(existing: LedgerEntry | undefined, hash: string): DuplicateClass {
167
+ if (!existing) return { kind: "new" };
168
+ if (existing.requestHash !== hash) return { kind: "conflict", entry: existing };
169
+ switch (existing.state) {
170
+ case "success":
171
+ return { kind: "reack_success", entry: existing };
172
+ case "failure":
173
+ return { kind: "reack_failure", entry: existing };
174
+ case "in_progress":
175
+ return { kind: "in_progress", entry: existing };
176
+ case "terminal_uncertain":
177
+ return { kind: "terminal_uncertain", entry: existing };
178
+ }
179
+ }
180
+
181
+ /** The structured outcome the daemon translates into a wire response frame. */
182
+ export type LifecycleOutcome =
183
+ | { status: "ok"; entry: LedgerEntry; mode?: "reattached" | "cold_restarted" }
184
+ | { status: "error"; reason: LifecycleErrorReason; message: string; candidates?: ResumeCandidate[] }
185
+ | { status: "pending"; entry: LedgerEntry };
186
+
187
+ /**
188
+ * Handle one authenticated lifecycle request. Enforces paired-chat gating,
189
+ * idempotency, and rate limiting BEFORE any side effect, then dispatches.
190
+ */
191
+ export async function handleLifecycleRequest(
192
+ frame: SessionLifecycleRequest,
193
+ deps: OrchestratorDeps,
194
+ ): Promise<LifecycleOutcome> {
195
+ const nowMs = deps.now();
196
+ const hash = requestHash(frame);
197
+ const key = ledgerKey(frame.chatId, frame.updateId);
198
+ const targetSummary = summarizeTarget(frame);
199
+
200
+ const baseAudit = {
201
+ ts: new Date(nowMs).toISOString(),
202
+ chatId: frame.chatId,
203
+ updateId: frame.updateId,
204
+ requestId: frame.requestId,
205
+ requestHash: hash,
206
+ verb: frame.type,
207
+ targetSummary,
208
+ } as const;
209
+
210
+ // 1. Strict paired-chat gating — BEFORE touching paths/processes or the ledger.
211
+ if (frame.chatId !== deps.pairedChatId) {
212
+ await deps.audit({ ...baseAudit, event: "rejected", reason: "unauthorized" });
213
+ return { status: "error", reason: "unauthorized", message: "chat not paired" };
214
+ }
215
+
216
+ // 2. Durable idempotency.
217
+ const doc = await deps.store.read();
218
+ const dup = classifyDuplicate(doc.entries[key], hash);
219
+ switch (dup.kind) {
220
+ case "conflict":
221
+ await deps.audit({ ...baseAudit, event: "rejected", reason: "duplicate_conflict" });
222
+ return { status: "error", reason: "duplicate_conflict", message: "update id reused with different body" };
223
+ case "reack_success":
224
+ await deps.audit({ ...baseAudit, event: "duplicate_reack", sessionId: dup.entry.sessionId });
225
+ return { status: "ok", entry: dup.entry };
226
+ case "reack_failure":
227
+ await deps.audit({ ...baseAudit, event: "duplicate_reack", reason: dup.entry.reason });
228
+ return {
229
+ status: "error",
230
+ reason: dup.entry.reason ?? "terminal_uncertain",
231
+ message: "previously failed; send a new update to retry",
232
+ };
233
+ case "in_progress":
234
+ // A retry arrived while the first attempt is still running: never
235
+ // respawn — report pending so the caller waits for the original.
236
+ await deps.audit({ ...baseAudit, event: "recovered_in_progress", sessionId: dup.entry.intendedSessionId });
237
+ return { status: "pending", entry: dup.entry };
238
+ case "terminal_uncertain":
239
+ await deps.audit({ ...baseAudit, event: "recovered_in_progress", reason: "terminal_uncertain" });
240
+ return {
241
+ status: "error",
242
+ reason: "terminal_uncertain",
243
+ message: "prior attempt outcome unknown; manual check",
244
+ };
245
+ case "new":
246
+ break;
247
+ }
248
+
249
+ // 3. Per-chat create rate limit (create only).
250
+ if (frame.type === "session_create" && !deps.allowCreate(frame.chatId, nowMs)) {
251
+ await deps.audit({ ...baseAudit, event: "rate_limited", reason: "rate_limited" });
252
+ return { status: "error", reason: "rate_limited", message: "create rate limit exceeded" };
253
+ }
254
+
255
+ // 4. Preallocate ids + write in_progress (fsynced) BEFORE any spawn.
256
+ const lifecycleRequestId = frame.type === "session_create" ? frame.lifecycleRequestId : deps.newLifecycleRequestId();
257
+ const intendedSessionId =
258
+ frame.type === "session_create" ? frame.intendedSessionId || deps.newSessionId() : deps.newSessionId();
259
+ let startupPromptRef: string | undefined;
260
+ let promptBytes: number | undefined;
261
+ let promptHash: string | undefined;
262
+
263
+ const entry: LedgerEntry = {
264
+ requestHash: hash,
265
+ state: "in_progress",
266
+ requestId: frame.requestId,
267
+ verb: frame.type,
268
+ intendedSessionId,
269
+ createdAt: nowMs,
270
+ updatedAt: nowMs,
271
+ targetSummary,
272
+ };
273
+ doc.entries[key] = entry;
274
+ await deps.store.write(doc);
275
+ await deps.audit({ ...baseAudit, event: "accepted", sessionId: intendedSessionId });
276
+
277
+ try {
278
+ if (frame.type === "session_create") {
279
+ startupPromptRef = await deps.writeStartupPrompt(frame.requestId, undefined);
280
+ entry.startupPromptRef = startupPromptRef;
281
+ await deps.audit({ ...baseAudit, event: "spawn_started", sessionId: intendedSessionId });
282
+ const result = await deps.spawnCreate(frame, { lifecycleRequestId, intendedSessionId, startupPromptRef });
283
+ Object.assign(entry, {
284
+ state: "success",
285
+ updatedAt: deps.now(),
286
+ sessionId: result.sessionId,
287
+ tmuxSession: result.tmuxSession,
288
+ sessionStateFile: result.sessionStateFile,
289
+ endpointUrl: result.endpointUrl,
290
+ });
291
+ await deps.store.write(doc);
292
+ await deps.audit({
293
+ ...baseAudit,
294
+ event: "success",
295
+ sessionId: result.sessionId,
296
+ tmuxSession: result.tmuxSession,
297
+ promptBytes,
298
+ promptHash,
299
+ });
300
+ return { status: "ok", entry };
301
+ }
302
+
303
+ if (frame.type === "session_close") {
304
+ const closed = await deps.closeSession(frame.target);
305
+ Object.assign(entry, {
306
+ state: "success",
307
+ updatedAt: deps.now(),
308
+ sessionId: frame.target.sessionId,
309
+ tmuxSession: frame.target.tmuxSession,
310
+ processGone: closed.processGone,
311
+ });
312
+ await deps.store.write(doc);
313
+ await deps.audit({ ...baseAudit, event: "success", sessionId: frame.target.sessionId });
314
+ return { status: "ok", entry };
315
+ }
316
+
317
+ // session_resume
318
+ const resumed = await deps.resumeSession(frame.target);
319
+ if ("ambiguous" in resumed) {
320
+ Object.assign(entry, { state: "failure", updatedAt: deps.now(), reason: "ambiguous_target" });
321
+ await deps.store.write(doc);
322
+ await deps.audit({ ...baseAudit, event: "failure", reason: "ambiguous_target" });
323
+ return {
324
+ status: "error",
325
+ reason: "ambiguous_target",
326
+ message: "multiple sessions match; pick one",
327
+ candidates: resumed.ambiguous,
328
+ };
329
+ }
330
+ if ("notFound" in resumed) {
331
+ Object.assign(entry, { state: "failure", updatedAt: deps.now(), reason: "not_found" });
332
+ await deps.store.write(doc);
333
+ await deps.audit({ ...baseAudit, event: "failure", reason: "not_found" });
334
+ return { status: "error", reason: "not_found", message: "no matching session found" };
335
+ }
336
+ Object.assign(entry, {
337
+ state: "success",
338
+ updatedAt: deps.now(),
339
+ sessionId: resumed.sessionId,
340
+ tmuxSession: resumed.tmuxSession,
341
+ endpointUrl: resumed.endpointUrl,
342
+ });
343
+ await deps.store.write(doc);
344
+ await deps.audit({ ...baseAudit, event: "success", sessionId: resumed.sessionId });
345
+ return { status: "ok", entry, mode: resumed.mode };
346
+ } catch (err) {
347
+ // A side effect may have occurred; do not auto-respawn. Mark terminal
348
+ // uncertain so a retry reconciles instead of duplicating.
349
+ Object.assign(entry, {
350
+ state: "terminal_uncertain",
351
+ updatedAt: deps.now(),
352
+ reason: "spawn_failed",
353
+ });
354
+ await deps.store.write(doc);
355
+ await deps.audit({ ...baseAudit, event: "terminal_uncertain", reason: "spawn_failed" });
356
+ return { status: "error", reason: "terminal_uncertain", message: `lifecycle effect failed: ${String(err)}` };
357
+ }
358
+ }
@@ -0,0 +1,171 @@
1
+ export interface NotificationOperatorTimerDeps {
2
+ now?: () => number;
3
+ setTimeoutImpl?: typeof setTimeout;
4
+ clearTimeoutImpl?: typeof clearTimeout;
5
+ setIntervalImpl?: typeof setInterval;
6
+ clearIntervalImpl?: typeof clearInterval;
7
+ }
8
+
9
+ export interface NotificationOperatorRuntimeState {
10
+ running: boolean;
11
+ stopRequested: boolean;
12
+ activeAbort: boolean;
13
+ }
14
+
15
+ export interface OperatorBackoffOptions {
16
+ initialMs: number;
17
+ maxMs: number;
18
+ factor?: number;
19
+ }
20
+
21
+ type OperatorIntervalHandle = number | NodeJS.Timeout;
22
+
23
+ export class OperatorBackoffPolicy {
24
+ #currentMs = 0;
25
+ #opts: OperatorBackoffOptions;
26
+
27
+ constructor(opts: OperatorBackoffOptions) {
28
+ this.#opts = opts;
29
+ }
30
+
31
+ next(): number {
32
+ this.#currentMs =
33
+ this.#currentMs === 0
34
+ ? this.#opts.initialMs
35
+ : Math.min(this.#currentMs * (this.#opts.factor ?? 2), this.#opts.maxMs);
36
+ return this.#currentMs;
37
+ }
38
+
39
+ reset(): void {
40
+ this.#currentMs = 0;
41
+ }
42
+
43
+ get currentMs(): number {
44
+ return this.#currentMs;
45
+ }
46
+ }
47
+
48
+ export interface OperatorRoute<TContext> {
49
+ name: string;
50
+ matches(event: Record<string, unknown>): boolean;
51
+ handle(context: TContext, event: Record<string, unknown>): Promise<void> | void;
52
+ }
53
+
54
+ export class OperatorEventRouter<TContext> {
55
+ readonly routes: OperatorRoute<TContext>[] = [];
56
+
57
+ add(input: OperatorRoute<TContext>): this {
58
+ this.routes.push(input);
59
+ return this;
60
+ }
61
+
62
+ async dispatch(context: TContext, event: Record<string, unknown>): Promise<boolean> {
63
+ for (const route of this.routes) {
64
+ if (!route.matches(event)) continue;
65
+ await route.handle(context, event);
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+ }
71
+
72
+ export class NotificationOperatorRuntime {
73
+ #running = false;
74
+ #stopRequested = false;
75
+ #activeAbort: AbortController | undefined;
76
+ #intervals = new Map<string, OperatorIntervalHandle>();
77
+ #exclusive = new Set<string>();
78
+
79
+ #deps: NotificationOperatorTimerDeps;
80
+
81
+ constructor(deps: NotificationOperatorTimerDeps = {}) {
82
+ this.#deps = deps;
83
+ }
84
+
85
+ get state(): NotificationOperatorRuntimeState {
86
+ return {
87
+ running: this.#running,
88
+ stopRequested: this.#stopRequested,
89
+ activeAbort: this.#activeAbort !== undefined,
90
+ };
91
+ }
92
+
93
+ start(): void {
94
+ this.#running = true;
95
+ this.#stopRequested = false;
96
+ }
97
+
98
+ stop(): void {
99
+ this.#running = false;
100
+ }
101
+
102
+ requestStop(): void {
103
+ this.#stopRequested = true;
104
+ this.#running = false;
105
+ this.#activeAbort?.abort();
106
+ }
107
+
108
+ get running(): boolean {
109
+ return this.#running;
110
+ }
111
+
112
+ get stopRequested(): boolean {
113
+ return this.#stopRequested;
114
+ }
115
+
116
+ createAbortController(): AbortController {
117
+ this.#activeAbort = new AbortController();
118
+ return this.#activeAbort;
119
+ }
120
+
121
+ clearAbortController(controller: AbortController): void {
122
+ if (this.#activeAbort === controller) this.#activeAbort = undefined;
123
+ }
124
+
125
+ startInterval(name: string, intervalMs: number, tick: () => void): void {
126
+ if (this.#intervals.has(name)) return;
127
+ const setIntervalImpl = this.#deps.setIntervalImpl ?? setInterval;
128
+ this.#intervals.set(name, setIntervalImpl(tick, intervalMs) as OperatorIntervalHandle);
129
+ }
130
+
131
+ stopInterval(name: string): void {
132
+ const timer = this.#intervals.get(name);
133
+ if (!timer) return;
134
+ const clearIntervalImpl = this.#deps.clearIntervalImpl ?? clearInterval;
135
+ clearIntervalImpl(timer);
136
+ this.#intervals.delete(name);
137
+ }
138
+
139
+ stopAllIntervals(): void {
140
+ for (const name of [...this.#intervals.keys()]) this.stopInterval(name);
141
+ }
142
+
143
+ async runExclusive(name: string, fn: () => Promise<void>): Promise<void> {
144
+ if (this.#exclusive.has(name)) return;
145
+ this.#exclusive.add(name);
146
+ try {
147
+ await fn();
148
+ } finally {
149
+ this.#exclusive.delete(name);
150
+ }
151
+ }
152
+
153
+ sleep(ms: number, signal?: AbortSignal): Promise<void> {
154
+ return new Promise<void>(resolve => {
155
+ if (signal?.aborted) return resolve();
156
+ const timer = (this.#deps.setTimeoutImpl ?? setTimeout)(() => resolve(), ms);
157
+ signal?.addEventListener(
158
+ "abort",
159
+ () => {
160
+ (this.#deps.clearTimeoutImpl ?? clearTimeout)(timer);
161
+ resolve();
162
+ },
163
+ { once: true },
164
+ );
165
+ });
166
+ }
167
+
168
+ now(): number {
169
+ return (this.#deps.now ?? Date.now)();
170
+ }
171
+ }
@@ -136,6 +136,25 @@ export class RateLimitPool<T = unknown> {
136
136
  return granted;
137
137
  }
138
138
 
139
+ /** Remove queued items matching `predicate` without consuming tokens. Returns removed items in lane/FIFO order. */
140
+ removeWhere(predicate: (item: RateLimitItem<T>) => boolean): RateLimitItem<T>[] {
141
+ const removed: RateLimitItem<T>[] = [];
142
+ for (const lane of LANE_PRIORITY) {
143
+ const queue = this.lanes.get(lane)!;
144
+ let write = 0;
145
+ for (let read = 0; read < queue.length; read++) {
146
+ const queued = queue[read]!;
147
+ if (predicate(queued.item)) {
148
+ removed.push(queued.item);
149
+ } else {
150
+ queue[write++] = queued;
151
+ }
152
+ }
153
+ queue.length = write;
154
+ }
155
+ return removed;
156
+ }
157
+
139
158
  private refill(nowMs: number): void {
140
159
  if (nowMs <= this.lastRefill) return;
141
160
  const elapsedSec = (nowMs - this.lastRefill) / 1000;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Recent-activity session picker (G006).
3
+ *
4
+ * Ranks GJC sessions by session-history file mtime (most recent first) and
5
+ * enriches each with terminal-breadcrumb info, so a remote lifecycle client can
6
+ * pick a repo to create in or a recent session to resume without typing raw
7
+ * paths. Dependency-light + injectable so it is unit-testable over a temp dir.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+
12
+ /** One ranked recent-session entry surfaced to the picker. */
13
+ export interface RecentSessionEntry {
14
+ /** Session id (the `.jsonl` file stem). */
15
+ sessionId: string;
16
+ /** Working directory / repo path, when recoverable from the header. */
17
+ path?: string;
18
+ /** Branch, when recoverable from the header. */
19
+ branch?: string;
20
+ /** A short title (first user message), when recoverable. */
21
+ title?: string;
22
+ /** Absolute path of the session history (state) file. */
23
+ sessionStateFile: string;
24
+ /** Last-activity epoch-millis (history file mtime). */
25
+ mtimeMs: number;
26
+ /** True when a terminal breadcrumb points at this session file. */
27
+ currentTerminal?: boolean;
28
+ }
29
+
30
+ export interface RecentActivityDeps {
31
+ /** Root holding `<encoded-cwd>/<sessionId>.jsonl` history files. */
32
+ sessionsRoot: string;
33
+ /** Optional breadcrumb session-file paths (current terminals). */
34
+ breadcrumbPaths?: string[];
35
+ /** Max entries to return (default 20). */
36
+ limit?: number;
37
+ /** Injection seam for tests. */
38
+ readFirstLine?: (file: string) => string | undefined;
39
+ }
40
+
41
+ function defaultReadFirstLine(file: string): string | undefined {
42
+ try {
43
+ const buf = fs.readFileSync(file, "utf8");
44
+ const nl = buf.indexOf("\n");
45
+ return nl === -1 ? buf : buf.slice(0, nl);
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ }
50
+
51
+ /** Best-effort header metadata extraction from a session file's first line. */
52
+ function headerMeta(line: string | undefined): { id?: string; path?: string; branch?: string; title?: string } {
53
+ if (!line) return {};
54
+ try {
55
+ const obj = JSON.parse(line) as Record<string, unknown>;
56
+ // Session headers vary; pull common fields defensively.
57
+ const id = typeof obj.id === "string" ? obj.id : undefined;
58
+ const cwd =
59
+ typeof obj.cwd === "string" ? obj.cwd : typeof obj.projectDir === "string" ? obj.projectDir : undefined;
60
+ const branch = typeof obj.branch === "string" ? obj.branch : undefined;
61
+ const title = typeof obj.title === "string" ? obj.title : undefined;
62
+ return { id, path: cwd, branch, title };
63
+ } catch {
64
+ return {};
65
+ }
66
+ }
67
+
68
+ /**
69
+ * The authoritative session id for a history file: the header `id` when present,
70
+ * else the filename stem with a leading `<timestamp>_` prefix stripped (matching
71
+ * SessionManager's `<isoTimestamp>_<id>.jsonl` naming), else the bare stem.
72
+ */
73
+ function sessionIdForFile(stem: string, headerId: string | undefined): string {
74
+ if (headerId) return headerId;
75
+ const m = stem.match(/^\d{4}-\d{2}-\d{2}T[\d:.-]+Z?_(.+)$/);
76
+ return m?.[1] ?? stem;
77
+ }
78
+
79
+ /**
80
+ * List recent sessions ranked by history-file mtime (newest first).
81
+ *
82
+ * Scans `<sessionsRoot>/<encoded-cwd>/<sessionId>.jsonl`, stats each file, and
83
+ * returns up to `limit` entries enriched with header metadata and a
84
+ * `currentTerminal` flag for any breadcrumb-referenced session file.
85
+ */
86
+ export function listRecentSessions(deps: RecentActivityDeps): RecentSessionEntry[] {
87
+ const limit = deps.limit ?? 20;
88
+ const readFirstLine = deps.readFirstLine ?? defaultReadFirstLine;
89
+ const breadcrumbs = new Set((deps.breadcrumbPaths ?? []).map(p => path.resolve(p)));
90
+
91
+ let projectDirs: string[];
92
+ try {
93
+ projectDirs = fs
94
+ .readdirSync(deps.sessionsRoot, { withFileTypes: true })
95
+ .filter(d => d.isDirectory())
96
+ .map(d => path.join(deps.sessionsRoot, d.name));
97
+ } catch {
98
+ return [];
99
+ }
100
+
101
+ const entries: RecentSessionEntry[] = [];
102
+ for (const dir of projectDirs) {
103
+ let files: string[];
104
+ try {
105
+ files = fs.readdirSync(dir).filter(name => name.endsWith(".jsonl"));
106
+ } catch {
107
+ continue;
108
+ }
109
+ for (const name of files) {
110
+ const file = path.join(dir, name);
111
+ let mtimeMs: number;
112
+ try {
113
+ mtimeMs = fs.statSync(file).mtimeMs;
114
+ } catch {
115
+ continue;
116
+ }
117
+ const meta = headerMeta(readFirstLine(file));
118
+ entries.push({
119
+ sessionId: sessionIdForFile(name.slice(0, -".jsonl".length), meta.id),
120
+ path: meta.path,
121
+ branch: meta.branch,
122
+ title: meta.title,
123
+ sessionStateFile: file,
124
+ mtimeMs,
125
+ currentTerminal: breadcrumbs.has(path.resolve(file)) || undefined,
126
+ });
127
+ }
128
+ }
129
+
130
+ entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
131
+ return entries.slice(0, limit);
132
+ }