///
/**
* Notifications extension.
*
* Hosts a per-session loopback WebSocket notification server (the Rust core via
* N-API) and bridges GJC session events + the `ask` tool to it so a remote client
* (e.g. a Telegram bot) can both see action-needed signals and answer them
* through SDK-native session capabilities:
*
* - `ask` (interactive): registers an {@link AskAnswerSource}; the ask tool races
* the local UI against a remote reply. First valid answer wins; a local answer
* aborts the remote wait (and broadcasts `action_resolved` resolvedBy=local).
* - `ask` (workflow gate): observes emitted workflow gates and resolves the real
* gate on a remote reply via `ctx.workflowGate`.
* - `turn_end` -> `action_needed` (kind `idle`, deduped per turn).
* - `session_shutdown` -> `session_closed` frame, stop server, deregister answer source.
*
* Enable with Settings notifications config, `GJC_NOTIFICATIONS=1` (a token is
* generated), or `GJC_NOTIFICATIONS_TOKEN`.
*/
import { AsyncLocalStorage } from "node:async_hooks";
import { execFile } from "node:child_process";
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { promisify } from "node:util";
import { type RunSettlementProof, ThinkingLevel } from "@gajae-code/agent-core";
import type { ImageContent, TextContent, Tool } from "@gajae-code/ai/core";
import type { NotificationServer as NativeNotificationServer } from "@gajae-code/natives";
type NativeSdkBusBindings = Pick;
let nativeSdkBusBindings: NativeSdkBusBindings | undefined;
/**
* Lazy native access for the SDK bus. `require` is synchronous on purpose:
* `startSession` must reach its `sessionStartPromises` registration without an
* intervening microtask yield, or two concurrent starts (two `/notify on`
* calls) each build a runtime and the loser observes a foreign registration.
*/
function sdkBusNatives(): NativeSdkBusBindings {
nativeSdkBusBindings ??= require("@gajae-code/natives") as NativeSdkBusBindings;
return nativeSdkBusBindings;
}
type NotificationServer = NativeNotificationServer;
import { $credentialEnv, logger, postmortem, VERSION } from "@gajae-code/utils";
import { AsyncJobManager } from "../../async";
import { Settings, validateSettingPatch } from "../../config/settings";
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions";
import { INTERACTIVE_SELECTOR_RESUME_ORIGIN } from "../../extensibility/shared-events";
import { toAgentWireEventPayload } from "../../modes/shared/agent-wire/event-envelope";
import {
NotificationGatePolicyChangedError,
type WorkflowGateEmitter,
type WorkflowGateTerminalController,
type WorkflowGateTerminalProof,
} from "../../modes/shared/agent-wire/workflow-gate-broker";
import type { AgentSessionEvent } from "../../session/agent-session";
import type { ClientBridge } from "../../session/client-bridge";
import {
boundTerminalRetentionState,
findOwnedRegistrationsForTurn,
isOwnedAttemptRegistrationIncomplete,
settleOwnedWork,
} from "../../session/terminal-abort";
import { parseThinkingLevel } from "../../thinking";
import type {
AskAnswerRequest,
AskAnswerSource,
AskAnswerSourceResult,
AskRemoteControl,
AskRemoteInteraction,
AskRemoteReceipt,
AskSelectedAckOutcome,
AskSettlement,
AskSettlementResult,
} from "../../tools";
import { RECOMMENDED_SUFFIX } from "../../tools/ask";
import {
GJC_ASK_TIMEOUT_CODE,
registerAskAnswerSource,
registerWorkflowGateEmitterListener,
} from "../../tools/ask-answer-registry";
import { acpFinalTextFromMessage } from "../acp/final-text";
import { ensureBroker } from "../broker/ensure";
import { publishSessionHostRuntimeEvidence, type SessionHostRuntimePublication } from "../broker/lifecycle";
import { processIncarnation } from "../broker/process-incarnation";
import { SessionIndex } from "../broker/session-index";
import {
CAP_GATED_FRAME_KINDS,
createSdkSurfaceFactory,
type SessionSdkHost,
SessionSdkSessionRuntime,
shouldHostSdk,
TOOL_ACTIVITY_CAPABILITY,
} from "../host";
import { type AbortScope, type ControlSurface, dispatchControl, TypedControlError } from "../host/control";
import { BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD } from "../host/control/runtime-gate";
import { isAutoroutingInactive, markAutoroutingInactive } from "../host/internal-autorouting-state";
import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from "../host/query";
import type { SdkFrame } from "../host/types";
import {
parseSyntheticModelId,
resolveSyntheticModelSelection,
SYNTHETIC_PROVIDER_ID,
syntheticModelInputError,
syntheticNamespaceCollision,
} from "../model-profile-model";
import { formatPromptFailureForLocalLog, sanitizePromptFailure } from "../prompt-failure";
import { PROMPT_CLIENT_REF_MAX_LENGTH, type SdkPromptTerminalOutcome } from "../prompt-status";
import { OPERATIONS } from "../protocol/operation-registry";
import {
lifecycleStartupCapabilityForApi,
normalizeSdkStartupFailure,
type SdkStartupFailure,
} from "../startup-capability";
import type { TurnResultContent } from "../turn-result";
import { registerTelegramFileSink } from "./attachment-registry";
import { ensureDiscordDaemon, ensureSlackDaemon } from "./chat-daemon-control";
import {
getCurrentTelegramActivationMarker,
getNotificationConfig,
isProviderEffectivelyEnabled,
isSlackComplete,
isTelegramSessionEligible,
type NotificationConfig,
type NotificationSettingsReader,
resolveGenericNotificationSessionEligibility,
tokenFingerprint,
} from "./config";
import { telegramControlCommandUsage } from "./config-commands";
import {
isNativeControlDrainAvailable,
runIdentityControlSuccessPath,
type TerminalSendOutcome,
} from "./control-drain-lease";
import { ConversationStore } from "./conversation-store";
import {
createSlackBindingActivationGate,
EXISTING_THREAD_BIND_ENV,
isExistingThreadBindingRequested,
} from "./existing-thread-readiness";
import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMessage, truncate } from "./helpers";
import {
createKindAwareReconciliation,
type KindAwareReconciliation,
type ReconciliationKind,
} from "./kind-aware-reconciliation";
import { assertNativeRuntimeCompatibility } from "./native-runtime-compatibility";
import { proposedTelegramIdentity } from "./notification-orchestration";
import { createPromptReconciliation } from "./prompt-reconciliation";
import {
createReconciliationStore,
type DurableTerminalScopeRecord,
type EvictedTerminalKeyEntry,
resolveReconciliationSessionFile,
} from "./reconciliation-store";
import { NotificationSessionController, type NotificationSessionRuntime } from "./session-control";
import type { SlackConversation } from "./slack-conversation";
import {
ASK_SELECTED_ACK_CAPABILITY,
type EnsureDaemonResult,
ensureTelegramDaemonRunningDetailed,
} from "./telegram-daemon";
export type {
IdentityControlSuccessPathInput,
IdentityControlTerminalPathInput,
TerminalSendOutcome,
} from "./control-drain-lease";
export {
isNativeControlDrainAvailable,
runIdentityControlSuccessPath,
runIdentityControlTerminalPath,
} from "./control-drain-lease";
export type NotificationInboundAdmission =
| { outcome: "accept" }
| { outcome: "drop"; reason: "inbound_fenced" | "policy_suspended" }
| { outcome: "defer"; reason: "policy_suspended" };
/** Exact production admission decision for daemon-originated session inbound. */
export function notificationInboundAdmission(input: {
inboundFenced: boolean;
policySuspended: boolean;
notificationOrigin: boolean;
controlCommand: boolean;
}): NotificationInboundAdmission {
if (input.inboundFenced) return { outcome: "drop", reason: "inbound_fenced" };
if (input.policySuspended && input.notificationOrigin) {
// Valid control commands are not dropped: they are deferred while policy is
// provisional and executed on activate. A terminal `dropped` ack would
// contradict that later execution and invite client-side retry duplication.
if (input.controlCommand) return { outcome: "defer", reason: "policy_suspended" };
return { outcome: "drop", reason: "policy_suspended" };
}
return { outcome: "accept" };
}
const PROMPT_SETTLEMENT_DIAGNOSTIC_ENTRY_LIMIT = 8;
const PROMPT_SETTLEMENT_DIAGNOSTIC_MAX_AGE_MS = 86_400_000;
/**
* Upper bound on the failure reason copied into the local operator log. Mirrors
* the 512-char bound documented for reconciliation failure messages so a runaway
* provider error cannot flood the log file.
*/
const PROMPT_TERMINAL_FAILURE_REASON_LOG_MAX = 512;
/**
* #4743: bounded wait for durable reconciliation quiescence during session
* teardown. Expiry is OBSERVABLE (owner-release failure), never silently
* treated as drained. Read per drain so the env override is effective for
* deterministic tests.
*/
const sdkReconciliationDrainTimeoutMs = (): number => {
const override = Number(process.env.GJC_SDK_RECONCILIATION_DRAIN_TIMEOUT_MS);
return override > 0 ? override : 5_000;
};
/**
* #4743: the two reconciliation failure codes that mean durable state may be
* lost. Both must reach the teardown owner; every other owner-release failure
* stays retryable through the retained `cleanupRetries` entry.
*/
const RECONCILIATION_DURABILITY_FAILURE_CODES: ReadonlySet = new Set([
"reconciliation_persist_failed",
"reconciliation_drain_timeout",
]);
function reconciliationFailureCode(value: unknown): string | undefined {
if (typeof value !== "object" || value === null) return undefined;
const code = (value as { code?: unknown }).code;
return typeof code === "string" && RECONCILIATION_DURABILITY_FAILURE_CODES.has(code) ? code : undefined;
}
/**
* Flatten an owner-release failure to its durability-failure members. Aggregates
* nest (the store batches a drained window's failures, and owner release batches
* every release failure), so recursion is required to reach the coded leaves.
*/
function reconciliationDurabilityFailures(error: unknown): unknown[] {
if (error instanceof AggregateError) return error.errors.flatMap(member => reconciliationDurabilityFailures(member));
return reconciliationFailureCode(error) === undefined ? [] : [error];
}
type PromptTerminalDiagnostic = {
reason?: unknown;
loopStopReason?: string;
assistantStopReason?: string;
errorKind?: string;
intentionalCancellation?: boolean;
};
type PromptTerminalExtra = {
finalText?: string;
error?: { code: string; message: string };
diagnostic?: PromptTerminalDiagnostic;
diagnosticAlreadyLogged?: boolean;
};
function formatPromptTerminalFailureReason(reason: unknown): string {
let rawReason: string;
if (reason instanceof Error) rawReason = reason.message;
else if (typeof reason === "string") rawReason = reason;
else if (reason === undefined || reason === null) return "unreported";
else
try {
rawReason = String(reason);
} catch {
return "unreported";
}
return rawReason ? rawReason.slice(0, PROMPT_TERMINAL_FAILURE_REASON_LOG_MAX) : "unreported";
}
/**
* Thrown from a serialized durable terminal-scope transaction when the
* idempotency key is already owned by a DIFFERENT input (scope). The generic
* dispatch cache normally rejects this before the surface, but after its
* 256-entry eviction two concurrent requests can both pass the earlier
* snapshot check; the atomic recheck inside the transaction must reject the
* second instead of appending a duplicate-key row (review thread P2).
*/
class TerminalIdempotencyConflictError extends Error {
constructor() {
super("Idempotency key was reused with different input.");
}
}
function endpointAuthorityDigest(url: string, token: string): string {
const parsed = new URL(url);
parsed.hash = "";
parsed.search = "";
parsed.hostname = parsed.hostname.toLowerCase();
return crypto.createHash("sha256").update(`${parsed.toString()} ${token}`, "utf8").digest("hex");
}
export function formatPromptSettlementDiagnostic(
proof: Extract,
now = Date.now(),
): string {
const pending = proof.pending.slice(0, PROMPT_SETTLEMENT_DIAGNOSTIC_ENTRY_LIMIT).map(entry => ({
kind: entry.kind,
labelHash: crypto.createHash("sha256").update(entry.label).digest("hex").slice(0, 16),
ageMs: Math.max(0, Math.min(PROMPT_SETTLEMENT_DIAGNOSTIC_MAX_AGE_MS, now - entry.registeredAt)),
}));
return JSON.stringify({
reason: proof.reason,
pending,
omitted: Math.max(0, proof.pending.length - pending.length),
});
}
// ===========================================================================
// Session lifecycle presentation contract
// ===========================================================================
// Provider-neutral lifecycle command targets and credential-free presentation outcomes.
// SessionLifecycleService owns request authorization/idempotency; these types contain no
// control endpoint, process, tmux, session-state, or SDK endpoint authority.
/** Where a `session_create` should run. Discriminated by `kind`. */
export type SessionCreateTarget =
| { kind: "existing_path"; path: string }
| { kind: "worktree"; repo: string; branch: string }
| { kind: "plain_dir"; path: string };
/** Identifies the session a `session_close` targets. */
export interface SessionCloseTarget {
sessionId: string;
}
/** Identifies the session a `session_resume` targets. */
export interface SessionResumeTarget {
sessionIdOrPrefix: string;
/** Optional repo/working-dir hint to disambiguate matches. */
path?: string;
}
export type LifecycleStatus = "ok" | "error";
export interface SessionCreateResponseFrame {
type: "session_create_response";
requestId: string;
status: LifecycleStatus;
sessionId: string;
target: SessionCreateTarget;
}
export interface SessionCloseResponseFrame {
type: "session_close_response";
requestId: string;
status: LifecycleStatus;
sessionId: string;
}
export type ResumeMode = "reattached" | "cold_restarted";
export interface SessionResumeResponseFrame {
type: "session_resume_response";
requestId: string;
status: LifecycleStatus;
sessionId: string;
mode: ResumeMode;
}
export type LifecycleErrorReason =
| "unauthorized"
| "rate_limited"
| "duplicate_conflict"
| "invalid_target"
| "ambiguous_target"
| "spawn_failed"
| "discovery_timeout"
| "readiness_timeout"
| "close_refused"
| "not_found"
| "terminal_uncertain"
| "unsupported_platform";
export interface ResumeCandidate {
sessionId: string;
path?: string;
mtimeMs?: number;
}
export interface SessionLifecycleErrorFrame {
type: "session_lifecycle_error";
requestId: string;
status: LifecycleStatus;
reason: LifecycleErrorReason;
message: string;
candidates?: ResumeCandidate[];
}
export type SessionLifecycleResponse =
| SessionCreateResponseFrame
| SessionCloseResponseFrame
| SessionResumeResponseFrame
| SessionLifecycleErrorFrame;
/**
* Replayable per-session readiness signal (mirror of the Rust `session_ready`
* frame). Buffered and replayed to late clients so WS-open alone never implies
* the session is live and surfaced.
*/
export interface SessionReadyFrame {
type: "session_ready";
sessionId: string;
lifecycleRequestId?: string;
startupPromptRef?: string;
repo?: string;
branch?: string;
title?: string;
}
/** Resolve the git dir for `cwd`, handling worktrees where `.git` is a file. */
function gitDir(cwd: string): string | undefined {
const dot = path.join(cwd, ".git");
try {
if (fs.statSync(dot).isDirectory()) return dot;
const m = fs
.readFileSync(dot, "utf8")
.trim()
.match(/^gitdir:\s*(.+)$/);
if (m) return path.resolve(cwd, m[1]);
} catch {}
return undefined;
}
/** Best-effort current branch from `.git/HEAD` (no git spawn). */
function readGitBranch(cwd: string): string | undefined {
const gd = gitDir(cwd);
if (!gd) return undefined;
try {
const head = fs.readFileSync(path.join(gd, "HEAD"), "utf8").trim();
const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
return m ? m[1] : head.slice(0, 12);
} catch {
return undefined;
}
}
/** Resolve the shared git dir (the main repo's `.git`) for a possibly-linked worktree. */
function gitCommonDir(gd: string): string {
try {
const raw = fs.readFileSync(path.join(gd, "commondir"), "utf8").trim();
if (raw) return path.resolve(gd, raw);
} catch {}
return gd;
}
/**
* Best-effort real repository name (no git spawn): resolves the main worktree
* root directory so linked worktrees report the repo (e.g. `gajae-code`)
* instead of the worktree directory (e.g. `feat-foo-01047f11`).
*/
export function readGitRepoName(cwd: string): string | undefined {
const gd = gitDir(cwd);
if (!gd) return undefined;
const commonDir = gitCommonDir(gd);
// Strip the trailing `.git` to land on the main worktree root directory.
const repoRoot = path.basename(commonDir) === ".git" ? path.dirname(commonDir) : commonDir;
const name = path.basename(repoRoot);
return name && name !== ".git" ? name : undefined;
}
/** Build the one-time identity header fields for a session thread. */
function buildIdentity(
cwd: string,
sessionName: string | undefined,
telegramTopicsEnabled: boolean,
): {
repo: string;
branch: string;
machine: string;
title?: string;
telegramTopicsEnabled: boolean;
} {
const repo = readGitRepoName(cwd) ?? (path.basename(cwd) || cwd);
const branch = readGitBranch(cwd) ?? "(detached)";
// Send repo/branch and the raw session title separately; the consumer
// composes the topic name ("{repo}/{branch}" before the session title is
// auto-generated, then "{repo}/{branch} - {session title}" once it exists).
return { repo, branch, machine: os.hostname(), title: sessionName, telegramTopicsEnabled };
}
/** Compact cwd label for remote session identity; never emits the full host path by default. */
function compactCwd(cwd: string): string | undefined {
const home = os.homedir();
const resolved = path.resolve(cwd);
if (resolved === home) return "~";
const base = path.basename(resolved);
return base || path.parse(resolved).root || undefined;
}
const execFileAsync = promisify(execFile);
/** Best-effort working-tree diff stat for the context update (no throw). */
async function readGitDiffStat(cwd: string): Promise {
try {
const { stdout } = await execFileAsync("git", ["-C", cwd, "diff", "--stat", "--no-color"], {
timeout: 3000,
maxBuffer: 256 * 1024,
});
const trimmed = stdout.trim();
return trimmed ? trimmed.slice(0, 1500) : undefined;
} catch {
return undefined;
}
}
interface PendingInteractiveAsk {
resolve: (result: AskAnswerSourceResult) => void;
options: string[];
controls: readonly AskRemoteControl[];
actionId?: string;
retireForDirectControl: () => RetireStatus;
reissue: () => boolean;
complete: (actionId: string) => void;
completeDirect: () => void;
fail: (actionId: string) => void;
}
interface UnattendedGatePresentation {
gateId: string;
sessionId: string;
question: string;
options: string[];
controls: readonly AskRemoteControl[];
recommendedIndex?: number;
multi: boolean;
allowEmpty: boolean;
navigationLabel?: "Next" | "Done";
selectedOptions: string[];
workflowGateId?: string;
onActivated?: (actionId: string, lease: { actionId: string; registrationEpoch: number }) => void;
onClosed?: () => void;
}
function recommendedIndexFromGateOptions(options: readonly unknown[]): number | undefined {
const descriptions = options.map(option => (option as { description?: unknown }).description);
const recommended = descriptions.filter(description => description === "recommended");
return recommended.length === 1 &&
descriptions.every(description => description === undefined || description === "recommended")
? descriptions.indexOf("recommended")
: undefined;
}
type RetireStatus = "retired" | "already_terminal" | "claimed" | "stale";
type DirectControlOutcome = "accepted" | "rejected" | "unknown";
interface PresentationRetentionOptions {
publish?: boolean;
sourceEpoch?: number;
}
type PreparedDirectControl =
| { status: "retired"; ordinal: number }
| {
status: "queued";
ordinal: number;
/** Exact proof retained from a previously published route, if any. */
terminalProof?: "retired" | "already_terminal";
};
interface DirectControlPreparationLease {
gateId: string;
presentation: UnattendedGatePresentation;
presentationGeneration: number;
sourceEpoch?: number;
}
function parseRetireStatus(status: string): RetireStatus {
if (status === "retired" || status === "already_terminal" || status === "claimed" || status === "stale")
return status;
throw new Error(`Unexpected native retirement status: ${status}`);
}
function isTerminalProof(status: RetireStatus): status is "retired" | "already_terminal" {
return status === "retired" || status === "already_terminal";
}
export class PresentationArbiter {
private readonly presentations = new Map();
private readonly routes = new Map();
private active: { actionId: string; gateId: string; registrationEpoch: number } | undefined;
private readonly queue: string[] = [];
private readonly retries = new Map();
private readonly retiredProofs = new Map();
/** Gate ids that have had a successfully registered presentation in this retention lifetime. */
private readonly publishedGateIds = new Set();
private readonly directControls = new Map();
/** Binds an in-flight direct control to the exact retained presentation it retired. */
private readonly directControlPreparations = new WeakMap