@gajae-code/coding-agent 0.7.3 → 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.
- package/CHANGELOG.md +48 -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 +20 -1
- 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 +61 -7
- 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 +27 -2
- package/src/gjc-runtime/tmux-sessions.ts +51 -1
- 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/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 +678 -7
- 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
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* - `ask` (unattended/RPC): observes emitted workflow gates and resolves the real
|
|
13
13
|
* gate on a remote reply via `ctx.workflowGate`.
|
|
14
14
|
* - `turn_end` -> `action_needed` (kind `idle`, deduped per turn).
|
|
15
|
-
* - `session_shutdown` -> stop
|
|
15
|
+
* - `session_shutdown` -> `session_closed` frame, stop server, deregister answer source.
|
|
16
16
|
*
|
|
17
17
|
* Enable with Settings notifications config, `GJC_NOTIFICATIONS=1` (a token is
|
|
18
18
|
* generated), or `GJC_NOTIFICATIONS_TOKEN`.
|
|
@@ -41,6 +41,193 @@ import {
|
|
|
41
41
|
import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMessage } from "./helpers";
|
|
42
42
|
import { ensureTelegramDaemonRunning } from "./telegram-daemon";
|
|
43
43
|
|
|
44
|
+
// ===========================================================================
|
|
45
|
+
// Session lifecycle control protocol (TypeScript mirror of the Rust wire
|
|
46
|
+
// contract in `crates/gjc-notifications/src/lifecycle.rs`).
|
|
47
|
+
//
|
|
48
|
+
// These describe the frames exchanged over the daemon-owned, session-independent
|
|
49
|
+
// control endpoint for remote session create / close / resume. Field names are
|
|
50
|
+
// camelCase on the wire; `type`/`kind` discriminators are snake_case. The Rust
|
|
51
|
+
// ingress authenticates and forwards; the daemon (TypeScript) owns all policy,
|
|
52
|
+
// spawn orchestration, idempotency, rate limiting, audit, and UX.
|
|
53
|
+
// ===========================================================================
|
|
54
|
+
|
|
55
|
+
/** Where a `session_create` should run. Discriminated by `kind`. */
|
|
56
|
+
export type SessionCreateTarget =
|
|
57
|
+
| { kind: "existing_path"; path: string }
|
|
58
|
+
| { kind: "worktree"; repo: string; branch: string }
|
|
59
|
+
| { kind: "plain_dir"; path: string };
|
|
60
|
+
|
|
61
|
+
/** Identifies the session a `session_close` targets. */
|
|
62
|
+
export interface SessionCloseTarget {
|
|
63
|
+
sessionId: string;
|
|
64
|
+
/** Expected GJC-managed tmux session name (defense-in-depth match). */
|
|
65
|
+
tmuxSession?: string;
|
|
66
|
+
/** Expected `@gjc-session-state-file` tag (defense-in-depth match). */
|
|
67
|
+
sessionStateFile?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Identifies the session a `session_resume` targets. */
|
|
71
|
+
export interface SessionResumeTarget {
|
|
72
|
+
sessionIdOrPrefix: string;
|
|
73
|
+
/** Optional repo/working-dir hint to disambiguate matches. */
|
|
74
|
+
path?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Create a new session. */
|
|
78
|
+
export interface SessionCreateFrame {
|
|
79
|
+
type: "session_create";
|
|
80
|
+
requestId: string;
|
|
81
|
+
/** Deterministic lifecycle marker preallocated by the daemon before spawn. */
|
|
82
|
+
lifecycleRequestId: string;
|
|
83
|
+
/** Session id the daemon preallocated and propagates to the child. */
|
|
84
|
+
intendedSessionId: string;
|
|
85
|
+
/** Telegram update id (idempotency key on the daemon side). */
|
|
86
|
+
updateId: number;
|
|
87
|
+
chatId: string;
|
|
88
|
+
/** Control-endpoint token authorizing this frame. */
|
|
89
|
+
token: string;
|
|
90
|
+
target: SessionCreateTarget;
|
|
91
|
+
/** Reference to the daemon-written, once-consumed startup-prompt file. */
|
|
92
|
+
startupPromptRef?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Close (hard-kill, history preserved) a session. */
|
|
96
|
+
export interface SessionCloseFrame {
|
|
97
|
+
type: "session_close";
|
|
98
|
+
requestId: string;
|
|
99
|
+
updateId: number;
|
|
100
|
+
chatId: string;
|
|
101
|
+
token: string;
|
|
102
|
+
target: SessionCloseTarget;
|
|
103
|
+
/** Hard-kill even if a live pane is attached (GJC-managed only). */
|
|
104
|
+
force?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Resume a session (reattach if alive, else cold-restart from history). */
|
|
108
|
+
export interface SessionResumeFrame {
|
|
109
|
+
type: "session_resume";
|
|
110
|
+
requestId: string;
|
|
111
|
+
updateId: number;
|
|
112
|
+
chatId: string;
|
|
113
|
+
token: string;
|
|
114
|
+
target: SessionResumeTarget;
|
|
115
|
+
startupPromptRef?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Any client -> ingress lifecycle request frame. */
|
|
119
|
+
export type SessionLifecycleRequest = SessionCreateFrame | SessionCloseFrame | SessionResumeFrame;
|
|
120
|
+
|
|
121
|
+
/** Terminal status of a lifecycle request. */
|
|
122
|
+
export type LifecycleStatus = "ok" | "error";
|
|
123
|
+
|
|
124
|
+
/** A connected session's per-session endpoint, returned to the control client. */
|
|
125
|
+
export interface LifecycleEndpoint {
|
|
126
|
+
url: string;
|
|
127
|
+
token: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The Telegram topic/thread a session is surfaced in. */
|
|
131
|
+
export interface LifecycleTopic {
|
|
132
|
+
chatId: string;
|
|
133
|
+
threadId: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** How a create request was correlated to its spawned session. */
|
|
137
|
+
export type MatchedBy = "spawn_marker" | "session_ready";
|
|
138
|
+
|
|
139
|
+
/** Response to a successful `session_create`. */
|
|
140
|
+
export interface SessionCreateResponseFrame {
|
|
141
|
+
type: "session_create_response";
|
|
142
|
+
requestId: string;
|
|
143
|
+
status: LifecycleStatus;
|
|
144
|
+
lifecycleRequestId: string;
|
|
145
|
+
sessionId: string;
|
|
146
|
+
matchedBy: MatchedBy;
|
|
147
|
+
endpoint: LifecycleEndpoint;
|
|
148
|
+
topic: LifecycleTopic;
|
|
149
|
+
target: SessionCreateTarget;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Response to a successful `session_close`. */
|
|
153
|
+
export interface SessionCloseResponseFrame {
|
|
154
|
+
type: "session_close_response";
|
|
155
|
+
requestId: string;
|
|
156
|
+
status: LifecycleStatus;
|
|
157
|
+
sessionId: string;
|
|
158
|
+
processGone: boolean;
|
|
159
|
+
historyPreserved: boolean;
|
|
160
|
+
endpointStale: boolean;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Whether a resume reattached to a live session or cold-restarted a dead one. */
|
|
164
|
+
export type ResumeMode = "reattached" | "cold_restarted";
|
|
165
|
+
|
|
166
|
+
/** Response to a successful `session_resume`. */
|
|
167
|
+
export interface SessionResumeResponseFrame {
|
|
168
|
+
type: "session_resume_response";
|
|
169
|
+
requestId: string;
|
|
170
|
+
status: LifecycleStatus;
|
|
171
|
+
sessionId: string;
|
|
172
|
+
mode: ResumeMode;
|
|
173
|
+
endpoint: LifecycleEndpoint;
|
|
174
|
+
topic: LifecycleTopic;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Machine-readable reason a lifecycle request failed. */
|
|
178
|
+
export type LifecycleErrorReason =
|
|
179
|
+
| "unauthorized"
|
|
180
|
+
| "rate_limited"
|
|
181
|
+
| "duplicate_conflict"
|
|
182
|
+
| "invalid_target"
|
|
183
|
+
| "ambiguous_target"
|
|
184
|
+
| "spawn_failed"
|
|
185
|
+
| "discovery_timeout"
|
|
186
|
+
| "readiness_timeout"
|
|
187
|
+
| "close_refused"
|
|
188
|
+
| "not_found"
|
|
189
|
+
| "terminal_uncertain";
|
|
190
|
+
|
|
191
|
+
/** A candidate returned with an `ambiguous_target` resume error. */
|
|
192
|
+
export interface ResumeCandidate {
|
|
193
|
+
sessionId: string;
|
|
194
|
+
path?: string;
|
|
195
|
+
/** Last-activity epoch-millis (session history file mtime), if known. */
|
|
196
|
+
mtimeMs?: number;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A structured lifecycle error frame. */
|
|
200
|
+
export interface SessionLifecycleErrorFrame {
|
|
201
|
+
type: "session_lifecycle_error";
|
|
202
|
+
requestId: string;
|
|
203
|
+
status: LifecycleStatus;
|
|
204
|
+
reason: LifecycleErrorReason;
|
|
205
|
+
message: string;
|
|
206
|
+
candidates?: ResumeCandidate[];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Any ingress -> client lifecycle response frame. */
|
|
210
|
+
export type SessionLifecycleResponse =
|
|
211
|
+
| SessionCreateResponseFrame
|
|
212
|
+
| SessionCloseResponseFrame
|
|
213
|
+
| SessionResumeResponseFrame
|
|
214
|
+
| SessionLifecycleErrorFrame;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Replayable per-session readiness signal (mirror of the Rust `session_ready`
|
|
218
|
+
* frame). Buffered and replayed to late clients so WS-open alone never implies
|
|
219
|
+
* the session is live and surfaced.
|
|
220
|
+
*/
|
|
221
|
+
export interface SessionReadyFrame {
|
|
222
|
+
type: "session_ready";
|
|
223
|
+
sessionId: string;
|
|
224
|
+
lifecycleRequestId?: string;
|
|
225
|
+
startupPromptRef?: string;
|
|
226
|
+
repo?: string;
|
|
227
|
+
branch?: string;
|
|
228
|
+
title?: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
44
231
|
/** Resolve the git dir for `cwd`, handling worktrees where `.git` is a file. */
|
|
45
232
|
function gitDir(cwd: string): string | undefined {
|
|
46
233
|
const dot = path.join(cwd, ".git");
|
|
@@ -141,6 +328,7 @@ interface SessionRuntime {
|
|
|
141
328
|
/** Deregisters this session's Telegram file sink. */
|
|
142
329
|
disposeFileSink: () => void;
|
|
143
330
|
redact: boolean;
|
|
331
|
+
verbosity: "lean" | "verbose";
|
|
144
332
|
sessionTag: string;
|
|
145
333
|
/** Whether the agent loop is currently running (drives the typing indicator). */
|
|
146
334
|
busy: boolean;
|
|
@@ -248,7 +436,7 @@ function registerInteractiveAnswerSource(
|
|
|
248
436
|
id: string,
|
|
249
437
|
server: NotificationServer,
|
|
250
438
|
pendingInteractive: Map<string, PendingInteractiveAsk>,
|
|
251
|
-
|
|
439
|
+
getRedact: () => boolean,
|
|
252
440
|
tag: string,
|
|
253
441
|
): () => void {
|
|
254
442
|
return registerAskAnswerSource(id, {
|
|
@@ -260,7 +448,7 @@ function registerInteractiveAnswerSource(
|
|
|
260
448
|
JSON.stringify(
|
|
261
449
|
notificationActionPayload(
|
|
262
450
|
{ id: askId, kind: "ask", sessionId: id, question, options },
|
|
263
|
-
{ redact, sessionTag: tag },
|
|
451
|
+
{ redact: getRedact(), sessionTag: tag },
|
|
264
452
|
),
|
|
265
453
|
),
|
|
266
454
|
true,
|
|
@@ -296,8 +484,9 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
296
484
|
const runtimes = new Map<string, SessionRuntime>();
|
|
297
485
|
const disabledSessions = new Set<string>();
|
|
298
486
|
const sessionId = (ctx: ExtensionContext): string => ctx.sessionManager.getSessionId();
|
|
487
|
+
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
|
|
299
488
|
|
|
300
|
-
function stopSession(id: string): boolean {
|
|
489
|
+
async function stopSession(id: string): Promise<boolean> {
|
|
301
490
|
const rt = runtimes.get(id);
|
|
302
491
|
if (!rt) return false;
|
|
303
492
|
runtimes.delete(id);
|
|
@@ -308,6 +497,14 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
308
497
|
// Resolve any still-pending interactive asks so the ask tool is not left hanging.
|
|
309
498
|
for (const pending of rt.pendingInteractive.values()) pending.resolve(undefined);
|
|
310
499
|
rt.pendingInteractive.clear();
|
|
500
|
+
let closeFrameSent = false;
|
|
501
|
+
try {
|
|
502
|
+
rt.server.pushFrame(JSON.stringify({ type: "session_closed", sessionId: id }));
|
|
503
|
+
closeFrameSent = true;
|
|
504
|
+
} catch (e) {
|
|
505
|
+
logger.warn(`notifications: session_closed failed: ${String(e)}`);
|
|
506
|
+
}
|
|
507
|
+
if (closeFrameSent) await sleep(100);
|
|
311
508
|
try {
|
|
312
509
|
rt.server.stop();
|
|
313
510
|
} catch (e) {
|
|
@@ -336,6 +533,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
336
533
|
const pendingInteractive = new Map<string, PendingInteractiveAsk>();
|
|
337
534
|
const tag = sessionTag(id);
|
|
338
535
|
const redact = cfg.redact;
|
|
536
|
+
const verbosity = cfg.verbosity;
|
|
339
537
|
let runtime: SessionRuntime | undefined;
|
|
340
538
|
|
|
341
539
|
// The SDK can always answer now (interactive via the answer source, or the
|
|
@@ -410,7 +608,31 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
410
608
|
return;
|
|
411
609
|
}
|
|
412
610
|
if (inbound.kind === "config_command") {
|
|
413
|
-
if (runtime
|
|
611
|
+
if (!runtime) return;
|
|
612
|
+
const update: {
|
|
613
|
+
type: "config_update";
|
|
614
|
+
sessionId: string;
|
|
615
|
+
verbosity?: "lean" | "verbose";
|
|
616
|
+
redact?: boolean;
|
|
617
|
+
} = {
|
|
618
|
+
type: "config_update",
|
|
619
|
+
sessionId: id,
|
|
620
|
+
};
|
|
621
|
+
if (inbound.verbosity === "lean" || inbound.verbosity === "verbose") {
|
|
622
|
+
runtime.verbosity = inbound.verbosity;
|
|
623
|
+
update.verbosity = inbound.verbosity;
|
|
624
|
+
}
|
|
625
|
+
if (typeof inbound.redact === "boolean") {
|
|
626
|
+
runtime.redact = inbound.redact;
|
|
627
|
+
update.redact = inbound.redact;
|
|
628
|
+
}
|
|
629
|
+
if (update.verbosity !== undefined || update.redact !== undefined) {
|
|
630
|
+
try {
|
|
631
|
+
runtime.server.pushFrame(JSON.stringify(update));
|
|
632
|
+
} catch (e) {
|
|
633
|
+
logger.warn(`notifications: config_update failed: ${String(e)}`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
414
636
|
}
|
|
415
637
|
});
|
|
416
638
|
|
|
@@ -418,7 +640,13 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
418
640
|
const endpoint = await server.start();
|
|
419
641
|
|
|
420
642
|
// Interactive answer source: the ask tool races the local UI against this.
|
|
421
|
-
const disposeAnswerSource = registerInteractiveAnswerSource(
|
|
643
|
+
const disposeAnswerSource = registerInteractiveAnswerSource(
|
|
644
|
+
id,
|
|
645
|
+
server,
|
|
646
|
+
pendingInteractive,
|
|
647
|
+
() => runtime?.redact ?? redact,
|
|
648
|
+
tag,
|
|
649
|
+
);
|
|
422
650
|
const disposeFileSink = registerTelegramFileSink(id, async file => {
|
|
423
651
|
try {
|
|
424
652
|
const data = await fs.promises.readFile(file.path);
|
|
@@ -444,6 +672,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
444
672
|
disposeAnswerSource,
|
|
445
673
|
disposeFileSink,
|
|
446
674
|
redact,
|
|
675
|
+
verbosity,
|
|
447
676
|
sessionTag: tag,
|
|
448
677
|
busy: false,
|
|
449
678
|
pendingInbound: new Set<number>(),
|
|
@@ -519,7 +748,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
519
748
|
|
|
520
749
|
if (command === "off") {
|
|
521
750
|
disabledSessions.add(id);
|
|
522
|
-
const stopped = stopSession(id);
|
|
751
|
+
const stopped = await stopSession(id);
|
|
523
752
|
ctx.ui.notify(
|
|
524
753
|
stopped
|
|
525
754
|
? "Notifications disabled for this session."
|
|
@@ -567,8 +796,9 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
567
796
|
const running = runtimes.has(id);
|
|
568
797
|
const locallyDisabled = disabledSessions.has(id);
|
|
569
798
|
const enabled = isEnabledForSession(id, resolved.cfg);
|
|
799
|
+
const runtime = runtimes.get(id);
|
|
570
800
|
ctx.ui.notify(
|
|
571
|
-
`Notifications ${running ? "running" : enabled ? "enabled" : "disabled"} for this session; redaction ${resolved.cfg.redact ? "on" : "off"}${locallyDisabled ? "; locally off" : ""}.`,
|
|
801
|
+
`Notifications ${running ? "running" : enabled ? "enabled" : "disabled"} for this session; redaction ${(runtime?.redact ?? resolved.cfg.redact) ? "on" : "off"}; verbosity ${runtime?.verbosity ?? resolved.cfg.verbosity}${locallyDisabled ? "; locally off" : ""}.`,
|
|
572
802
|
"info",
|
|
573
803
|
);
|
|
574
804
|
},
|
|
@@ -616,7 +846,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
616
846
|
newId,
|
|
617
847
|
rt.server,
|
|
618
848
|
rt.pendingInteractive,
|
|
619
|
-
rt.redact,
|
|
849
|
+
() => rt.redact,
|
|
620
850
|
rt.sessionTag,
|
|
621
851
|
);
|
|
622
852
|
rt.disposeFileSink = registerTelegramFileSink(newId, async file => {
|
|
@@ -735,7 +965,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
735
965
|
// On idle, stream a context update with metadata (token/model usage +
|
|
736
966
|
// working-tree diff) unless redaction is on. The agent's last message is
|
|
737
967
|
// NOT repeated here — it is already streamed once via `turn_stream`.
|
|
738
|
-
if (!rt.redact) {
|
|
968
|
+
if (!rt.redact && rt.verbosity === "verbose") {
|
|
739
969
|
const usage = (
|
|
740
970
|
ctx as { getContextUsage?: () => { tokens: number | null; contextWindow: number } | undefined }
|
|
741
971
|
).getContextUsage?.();
|
|
@@ -836,7 +1066,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
836
1066
|
}
|
|
837
1067
|
});
|
|
838
1068
|
|
|
839
|
-
api.on("session_shutdown", (_event, ctx) => {
|
|
840
|
-
stopSession(sessionId(ctx));
|
|
1069
|
+
api.on("session_shutdown", async (_event, ctx) => {
|
|
1070
|
+
await stopSession(sessionId(ctx));
|
|
841
1071
|
});
|
|
842
1072
|
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paired-chat /session_* command grammar (G009).
|
|
3
|
+
*
|
|
4
|
+
* Pure parser + shared target validator for the Telegram session-lifecycle
|
|
5
|
+
* commands. The daemon parses an inbound paired-chat message here, then attaches
|
|
6
|
+
* transport identity (chatId/updateId/token/requestId) and routes the resulting
|
|
7
|
+
* frame to the orchestrator. Keeping this pure makes the grammar, the MVP
|
|
8
|
+
* prompt-rejection, and target validation unit-testable without the daemon.
|
|
9
|
+
*
|
|
10
|
+
* MVP scope: an initial prompt (`-- <prompt>`) is REJECTED with usage text — no
|
|
11
|
+
* prompt text ever enters a frame, audit, log, or response until daemon-owned
|
|
12
|
+
* 0600 prompt refs are designed.
|
|
13
|
+
*/
|
|
14
|
+
import type { SessionCloseTarget, SessionCreateTarget, SessionLifecycleResponse, SessionResumeTarget } from "./index";
|
|
15
|
+
|
|
16
|
+
export type LifecycleCommandVerb = "session_create" | "session_close" | "session_resume";
|
|
17
|
+
|
|
18
|
+
/** A parsed, validated lifecycle command (transport identity added by caller). */
|
|
19
|
+
export type ParsedLifecycleCommand =
|
|
20
|
+
| { kind: "create"; target: SessionCreateTarget }
|
|
21
|
+
| { kind: "close"; target: SessionCloseTarget }
|
|
22
|
+
| { kind: "resume"; target: SessionResumeTarget }
|
|
23
|
+
| { kind: "recent"; which: "create" | "resume" | "all" }
|
|
24
|
+
| { kind: "usage"; message: string }
|
|
25
|
+
| { kind: "reject"; reason: "invalid_target" | "prompt_unsupported"; message: string }
|
|
26
|
+
| { kind: "none" };
|
|
27
|
+
|
|
28
|
+
const USAGE = [
|
|
29
|
+
"Session commands:",
|
|
30
|
+
"/session_create path <dir>",
|
|
31
|
+
"/session_create worktree <repo> <branch>",
|
|
32
|
+
"/session_create dir <newdir>",
|
|
33
|
+
"/session_close <sessionId>",
|
|
34
|
+
"/session_resume <sessionId|prefix>",
|
|
35
|
+
"/session_recent [create|resume]",
|
|
36
|
+
].join("\n");
|
|
37
|
+
|
|
38
|
+
/** True when the text begins a /session_* command (cheap pre-gate). */
|
|
39
|
+
export function isLifecycleCommandText(text: string | undefined): boolean {
|
|
40
|
+
if (!text) return false;
|
|
41
|
+
return /^\/session_(create|close|resume|recent)\b/.test(text.trim());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse a paired-chat message into a lifecycle command. Returns `none` for
|
|
46
|
+
* non-lifecycle text, `usage`/`reject` for malformed input (no side effect), or
|
|
47
|
+
* a validated `create`/`close`/`resume`/`recent` intent.
|
|
48
|
+
*
|
|
49
|
+
* The caller MUST have already enforced paired-chat authorization; this function
|
|
50
|
+
* performs grammar + target validation only.
|
|
51
|
+
*/
|
|
52
|
+
export function parseLifecycleCommand(text: string | undefined): ParsedLifecycleCommand {
|
|
53
|
+
if (!isLifecycleCommandText(text)) return { kind: "none" };
|
|
54
|
+
const raw = (text ?? "").trim();
|
|
55
|
+
|
|
56
|
+
// MVP: reject any initial-prompt separator outright (no prompt handling yet).
|
|
57
|
+
if (/\s--(\s|$)/.test(raw)) {
|
|
58
|
+
return {
|
|
59
|
+
kind: "reject",
|
|
60
|
+
reason: "prompt_unsupported",
|
|
61
|
+
message: `Initial prompts (\`-- <prompt>\`) are not supported yet. Create the session, then send a normal message in its thread.\n\n${USAGE}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const [command, ...args] = raw.split(/\s+/);
|
|
66
|
+
|
|
67
|
+
if (command === "/session_recent") {
|
|
68
|
+
const which = args[0];
|
|
69
|
+
if (which === undefined || which === "create" || which === "resume") {
|
|
70
|
+
return { kind: "recent", which: which ?? "all" };
|
|
71
|
+
}
|
|
72
|
+
return { kind: "usage", message: USAGE };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (command === "/session_close") {
|
|
76
|
+
if (args.length !== 1) return { kind: "usage", message: USAGE };
|
|
77
|
+
const sessionId = args[0]!;
|
|
78
|
+
if (!isSafeIdentifier(sessionId)) {
|
|
79
|
+
return { kind: "reject", reason: "invalid_target", message: `Invalid session id.\n\n${USAGE}` };
|
|
80
|
+
}
|
|
81
|
+
return { kind: "close", target: { sessionId } };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (command === "/session_resume") {
|
|
85
|
+
if (args.length !== 1) return { kind: "usage", message: USAGE };
|
|
86
|
+
const idOrPrefix = args[0]!;
|
|
87
|
+
if (!isSafeIdentifier(idOrPrefix)) {
|
|
88
|
+
return { kind: "reject", reason: "invalid_target", message: `Invalid session id/prefix.\n\n${USAGE}` };
|
|
89
|
+
}
|
|
90
|
+
return { kind: "resume", target: { sessionIdOrPrefix: idOrPrefix } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// /session_create <kind> ...
|
|
94
|
+
const kind = args[0];
|
|
95
|
+
if (kind === "path") {
|
|
96
|
+
if (args.length !== 2) return { kind: "usage", message: USAGE };
|
|
97
|
+
const p = args[1]!;
|
|
98
|
+
if (!isSafePath(p)) return { kind: "reject", reason: "invalid_target", message: `Invalid path.\n\n${USAGE}` };
|
|
99
|
+
return { kind: "create", target: { kind: "existing_path", path: p } };
|
|
100
|
+
}
|
|
101
|
+
if (kind === "dir") {
|
|
102
|
+
if (args.length !== 2) return { kind: "usage", message: USAGE };
|
|
103
|
+
const p = args[1]!;
|
|
104
|
+
if (!isSafePath(p)) return { kind: "reject", reason: "invalid_target", message: `Invalid dir.\n\n${USAGE}` };
|
|
105
|
+
return { kind: "create", target: { kind: "plain_dir", path: p } };
|
|
106
|
+
}
|
|
107
|
+
if (kind === "worktree") {
|
|
108
|
+
if (args.length !== 3) return { kind: "usage", message: USAGE };
|
|
109
|
+
const repo = args[1]!;
|
|
110
|
+
const branch = args[2]!;
|
|
111
|
+
if (!isSafePath(repo))
|
|
112
|
+
return { kind: "reject", reason: "invalid_target", message: `Invalid repo path.\n\n${USAGE}` };
|
|
113
|
+
if (!isSafeBranch(branch)) {
|
|
114
|
+
return { kind: "reject", reason: "invalid_target", message: `Invalid branch name.\n\n${USAGE}` };
|
|
115
|
+
}
|
|
116
|
+
return { kind: "create", target: { kind: "worktree", repo, branch } };
|
|
117
|
+
}
|
|
118
|
+
return { kind: "usage", message: USAGE };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The canonical usage text (exported for the daemon's help replies). */
|
|
122
|
+
export function lifecycleUsage(): string {
|
|
123
|
+
return USAGE;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Shared target validator reused at the policy/effect boundary (after paired-chat
|
|
128
|
+
* auth, before any side effect). Returns null when valid, or an `invalid_target`
|
|
129
|
+
* reason. The orchestrator remains authoritative; this is a defensive pre-check
|
|
130
|
+
* the parser and any other entry point share.
|
|
131
|
+
*/
|
|
132
|
+
export function validateLifecycleTarget(
|
|
133
|
+
verb: LifecycleCommandVerb,
|
|
134
|
+
target: SessionCreateTarget | SessionCloseTarget | SessionResumeTarget,
|
|
135
|
+
): { ok: true } | { ok: false; reason: "invalid_target"; message: string } {
|
|
136
|
+
const bad = (message: string) => ({ ok: false as const, reason: "invalid_target" as const, message });
|
|
137
|
+
if (verb === "session_create") {
|
|
138
|
+
const t = target as SessionCreateTarget;
|
|
139
|
+
if (t.kind === "existing_path" || t.kind === "plain_dir") {
|
|
140
|
+
return isSafePath(t.path) ? { ok: true } : bad("invalid path");
|
|
141
|
+
}
|
|
142
|
+
if (t.kind === "worktree") {
|
|
143
|
+
if (!isSafePath(t.repo)) return bad("invalid repo path");
|
|
144
|
+
return isSafeBranch(t.branch) ? { ok: true } : bad("invalid branch");
|
|
145
|
+
}
|
|
146
|
+
return bad("unknown create target");
|
|
147
|
+
}
|
|
148
|
+
if (verb === "session_close") {
|
|
149
|
+
const t = target as SessionCloseTarget;
|
|
150
|
+
return isSafeIdentifier(t.sessionId) ? { ok: true } : bad("invalid session id");
|
|
151
|
+
}
|
|
152
|
+
const t = target as SessionResumeTarget;
|
|
153
|
+
return isSafeIdentifier(t.sessionIdOrPrefix) ? { ok: true } : bad("invalid session id/prefix");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- Safety primitives (defensive; the full-trust paired chat is accepted, but
|
|
157
|
+
// we still reject obviously malformed/injection-shaped inputs early). ---
|
|
158
|
+
|
|
159
|
+
function isSafeIdentifier(value: string): boolean {
|
|
160
|
+
return /^[A-Za-z0-9._-]{1,128}$/.test(value);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isSafePath(value: string): boolean {
|
|
164
|
+
// Reject empty, shell-metacharacter, or newline-bearing paths. Absolute or
|
|
165
|
+
// relative are both allowed (full-trust chat), but not injection shapes.
|
|
166
|
+
if (value.length === 0 || value.length > 4096) return false;
|
|
167
|
+
if (/[\n\r\0]/.test(value)) return false;
|
|
168
|
+
return !/[;&|`$(){}<>*?!\\"']/.test(value);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isSafeBranch(value: string): boolean {
|
|
172
|
+
// Defense-in-depth: also reject leading-hyphen names so a branch can never be
|
|
173
|
+
// mistaken for a CLI flag downstream.
|
|
174
|
+
return /^[A-Za-z0-9._/-]{1,255}$/.test(value) && !value.includes("..") && !value.startsWith("-");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Map a lifecycle response/error to a user-facing Telegram message (G010).
|
|
179
|
+
*
|
|
180
|
+
* Only derives text from sessionId, mode, reason, a safe message, and candidate
|
|
181
|
+
* {sessionId,path} — never a token or prompt. Each error reason gets tailored,
|
|
182
|
+
* actionable copy; an "in progress" pending response is surfaced distinctly.
|
|
183
|
+
*/
|
|
184
|
+
export function formatLifecycleOutcome(r: SessionLifecycleResponse): string {
|
|
185
|
+
switch (r.type) {
|
|
186
|
+
case "session_create_response":
|
|
187
|
+
return `\u{1f680} Launching session ${r.sessionId} in tmux. It will appear once ready \u2014 check /session_recent.`;
|
|
188
|
+
case "session_close_response":
|
|
189
|
+
return `\u2705 Closed session ${r.sessionId} (history preserved \u2014 you can resume it later).`;
|
|
190
|
+
case "session_resume_response":
|
|
191
|
+
return r.mode === "reattached"
|
|
192
|
+
? `\u2705 Reattached to live session ${r.sessionId}.`
|
|
193
|
+
: `\u{1f680} Cold-restarting session ${r.sessionId} from saved history in tmux \u2014 check /session_recent.`;
|
|
194
|
+
case "session_lifecycle_error":
|
|
195
|
+
break;
|
|
196
|
+
default:
|
|
197
|
+
return "Unknown lifecycle response.";
|
|
198
|
+
}
|
|
199
|
+
if (r.reason === "ambiguous_target" && r.candidates?.length) {
|
|
200
|
+
const list = r.candidates.map(c => `\u2022 ${c.sessionId}${c.path ? ` (${c.path})` : ""}`).join("\n");
|
|
201
|
+
return `\u2753 Multiple sessions match \u2014 reply with the exact id:\n${list}`;
|
|
202
|
+
}
|
|
203
|
+
switch (r.reason) {
|
|
204
|
+
case "unauthorized":
|
|
205
|
+
return "\u26d4 Not authorized for session lifecycle commands.";
|
|
206
|
+
case "rate_limited":
|
|
207
|
+
return "\u23f3 Too many create requests \u2014 please wait a bit and try again.";
|
|
208
|
+
case "duplicate_conflict":
|
|
209
|
+
return "\u26a0\ufe0f That command id was already used for a different request; send a fresh command.";
|
|
210
|
+
case "invalid_target":
|
|
211
|
+
return `\u26a0\ufe0f Invalid target. ${r.message}`;
|
|
212
|
+
case "spawn_failed":
|
|
213
|
+
return "\u26a0\ufe0f The session failed to start. Nothing was left running.";
|
|
214
|
+
case "discovery_timeout":
|
|
215
|
+
case "readiness_timeout":
|
|
216
|
+
return "\u23f3 The session did not become ready in time. It may still be starting \u2014 check /session_recent.";
|
|
217
|
+
case "close_refused":
|
|
218
|
+
return "\u26a0\ufe0f Close refused: that session is not GJC-managed or did not match.";
|
|
219
|
+
case "not_found":
|
|
220
|
+
return "\u2753 No matching session was found.";
|
|
221
|
+
case "terminal_uncertain":
|
|
222
|
+
return /in progress/i.test(r.message)
|
|
223
|
+
? "\u23f3 That request is already in progress \u2014 hold on."
|
|
224
|
+
: "\u26a0\ufe0f Outcome uncertain. Check /session_recent before retrying so you don't double-spawn.";
|
|
225
|
+
default:
|
|
226
|
+
return `\u26a0\ufe0f ${r.reason}: ${r.message}`;
|
|
227
|
+
}
|
|
228
|
+
}
|