@gajae-code/coding-agent 0.7.3 → 0.7.5
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 +58 -0
- package/bin/gjc.js +4 -0
- package/dist/types/cli/plugin-cli.d.ts +2 -0
- package/dist/types/commands/plugin.d.ts +6 -0
- package/dist/types/commands/session.d.ts +6 -0
- package/dist/types/config/model-profile-activation.d.ts +8 -1
- package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
- package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
- package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
- package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
- package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
- package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
- package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
- package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
- package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
- package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
- package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
- package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
- package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +30 -2
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/model-selector.d.ts +6 -0
- package/dist/types/notifications/html-format.d.ts +11 -0
- package/dist/types/notifications/index.d.ts +149 -1
- package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
- package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
- package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
- package/dist/types/notifications/recent-activity.d.ts +35 -0
- package/dist/types/notifications/telegram-daemon.d.ts +60 -0
- package/dist/types/notifications/telegram-reference.d.ts +3 -1
- package/dist/types/notifications/topic-registry.d.ts +10 -9
- package/dist/types/runtime-mcp/types.d.ts +7 -0
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +14 -4
- package/dist/types/session/blob-store.d.ts +25 -0
- package/dist/types/session/session-manager.d.ts +57 -0
- package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +9 -1
- package/dist/types/tools/index.d.ts +3 -1
- package/dist/types/utils/changelog.d.ts +1 -0
- package/package.json +11 -9
- package/scripts/g004-tmux-smoke.ts +100 -0
- package/scripts/g005-daemon-smoke.ts +181 -0
- package/scripts/g011-daemon-path-smoke.ts +153 -0
- package/src/cli/plugin-cli.ts +66 -3
- package/src/cli.ts +21 -4
- package/src/commands/plugin.ts +4 -0
- package/src/commands/session.ts +18 -0
- package/src/config/model-profile-activation.ts +55 -7
- package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +3 -3
- package/src/defaults/gjc/skills/team/SKILL.md +5 -4
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
- package/src/export/html/index.ts +2 -2
- package/src/extensibility/gjc-plugins/compiler.ts +351 -0
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
- package/src/extensibility/gjc-plugins/index.ts +9 -0
- package/src/extensibility/gjc-plugins/injection.ts +109 -0
- package/src/extensibility/gjc-plugins/installer.ts +434 -0
- package/src/extensibility/gjc-plugins/loader.ts +3 -1
- package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
- package/src/extensibility/gjc-plugins/observability.ts +84 -0
- package/src/extensibility/gjc-plugins/paths.ts +1 -1
- package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
- package/src/extensibility/gjc-plugins/registry.ts +180 -0
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
- package/src/extensibility/gjc-plugins/schema.ts +250 -20
- package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
- package/src/extensibility/gjc-plugins/types.ts +199 -3
- package/src/extensibility/gjc-plugins/validation.ts +80 -0
- package/src/extensibility/skills.ts +15 -0
- package/src/gjc-runtime/launch-tmux.ts +58 -15
- package/src/gjc-runtime/psmux-detect.ts +239 -0
- package/src/gjc-runtime/team-runtime.ts +56 -23
- package/src/gjc-runtime/tmux-common.ts +85 -3
- package/src/gjc-runtime/tmux-sessions.ts +111 -9
- package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
- package/src/internal-urls/docs-index.generated.ts +5 -4
- package/src/main.ts +14 -3
- package/src/modes/components/assistant-message.ts +49 -1
- package/src/modes/components/hook-editor.ts +1 -1
- package/src/modes/components/hook-selector.ts +67 -43
- package/src/modes/components/model-selector.ts +44 -11
- package/src/modes/controllers/extension-ui-controller.ts +0 -27
- package/src/modes/controllers/selector-controller.ts +50 -11
- package/src/modes/interactive-mode.ts +2 -0
- package/src/modes/utils/hotkeys-markdown.ts +1 -1
- package/src/notifications/html-format.ts +38 -0
- package/src/notifications/index.ts +242 -12
- package/src/notifications/lifecycle-commands.ts +228 -0
- package/src/notifications/lifecycle-control-runtime.ts +400 -0
- package/src/notifications/lifecycle-orchestrator.ts +358 -0
- package/src/notifications/rate-limit-pool.ts +19 -0
- package/src/notifications/recent-activity.ts +132 -0
- package/src/notifications/telegram-daemon.ts +433 -8
- package/src/notifications/telegram-reference.ts +25 -7
- package/src/notifications/topic-registry.ts +18 -9
- package/src/prompts/agents/executor.md +2 -2
- package/src/runtime-mcp/transports/stdio.ts +38 -4
- package/src/runtime-mcp/types.ts +7 -0
- package/src/sdk.ts +157 -10
- package/src/session/agent-session.ts +166 -74
- package/src/session/blob-store.ts +196 -8
- package/src/session/session-manager.ts +739 -12
- package/src/slash-commands/builtin-registry.ts +23 -3
- package/src/slash-commands/helpers/fast-status-report.ts +13 -3
- package/src/system-prompt.ts +9 -0
- package/src/task/executor.ts +31 -7
- package/src/task/index.ts +2 -0
- package/src/tools/ask.ts +5 -1
- package/src/tools/index.ts +3 -1
- package/src/utils/changelog.ts +8 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|