gentle-pi 2.5.0 → 2.6.1
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/README.md +139 -30
- package/assets/agents/gentle-ai-worker.md +4 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +21 -3
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +67 -0
- package/docs/telemetry.md +57 -1
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +143 -15
- package/extensions/codegraph-tools.ts +1 -0
- package/extensions/gentle-agents.ts +795 -50
- package/extensions/gentle-ai.ts +2033 -322
- package/extensions/gentle-shell.ts +145 -42
- package/extensions/gentle-todo.ts +47 -12
- package/extensions/quiet-tools.ts +1 -0
- package/extensions/runtime-metrics.ts +121 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +7 -10
- package/lib/agents-history.ts +9 -1
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +77 -5
- package/lib/agents-runner.ts +548 -26
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +548 -191
- package/lib/agents-widget.ts +33 -14
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +8 -6
- package/lib/native-review-cli.ts +288 -1
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +296 -46
- package/lib/review-candidate-view.ts +30 -20
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +53 -8
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +187 -5
- package/lib/review-last-event-controller.ts +7 -4
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-session-standing-permission.ts +27 -6
- package/lib/runtime-metrics-children.ts +197 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +174 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +308 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +47 -1
- package/lib/shell-card.ts +1 -4
- package/lib/shell-changes-view.ts +362 -37
- package/lib/shell-changes.ts +81 -1
- package/lib/shell-prompt.ts +11 -15
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +28 -11
- package/lib/telemetry-trigger.ts +2 -0
- package/package.json +6 -3
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +288 -1
- package/runtime/review-integration-v2.mjs +187 -5
- package/runtime/telemetry-trigger.mjs +2 -0
- package/scripts/build-runtime-modules.mjs +9 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +16 -2
- package/scripts/types-baseline.json +95 -0
- package/scripts/verify-package-files.mjs +4 -2
- package/skills/_shared/review-ledger-contract.md +17 -1
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +62 -0
- package/tests/agents-fake-child.ts +15 -1
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +45 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner.test.ts +571 -14
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-view.test.ts +476 -65
- package/tests/agents-widget.test.ts +31 -1
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +169 -3
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +2 -1
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +2 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +1454 -33
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +38 -0
- package/tests/gentle-ai.test.ts +944 -4
- package/tests/gentle-shell.test.ts +303 -12
- package/tests/gentle-todo.test.ts +54 -10
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-review-capability-contract.test.ts +23 -1
- package/tests/native-review-cli.test.ts +277 -3
- package/tests/native-review-parity.test.ts +14 -7
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/package-manifest.test.ts +232 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +1 -0
- package/tests/rdd-aware-verification-contract.test.ts +10 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +304 -6
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +14 -0
- package/tests/review-controller-native-routing.test.ts +563 -3
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +364 -4
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +164 -0
- package/tests/review-last-event-closure.test.ts +105 -1
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-session-standing-permission-controller.test.ts +52 -4
- package/tests/review-session-standing-permission.test.ts +30 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +207 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +196 -0
- package/tests/runtime-metrics-model.test.ts +76 -0
- package/tests/runtime-metrics-native.test.ts +275 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +187 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-card.test.ts +24 -3
- package/tests/shell-changes-view.test.ts +471 -8
- package/tests/shell-changes.test.ts +168 -0
- package/tests/shell-prompt.test.ts +28 -6
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +100 -11
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +3 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { parseAgentDefinition, type AgentDefinition, type ModelRef } from "./agents-config.ts";
|
|
3
|
+
import type { ChildObservationSnapshot } from "./agents-runner.ts";
|
|
4
|
+
import { FINISHED_STATUSES, type TaskStatus } from "./agents-protocol.ts";
|
|
5
|
+
import { classifyRuntimeModelId, EFFORTS, normalizeRuntimeProvider, parseAgentClass, RuntimeMetrics, UNKNOWN_AGENT_CLASS, validRuntimeResponse, type AgentClass, type FinalResponse, type RuntimeMetricBucket } from "./runtime-metrics.ts";
|
|
6
|
+
export const CHILD_METRICS_EVENT = "gentle:runtime-metrics:child/v1";
|
|
7
|
+
// Local revocation notification invalidates active observations. Contains only
|
|
8
|
+
// the local session join, never policy output.
|
|
9
|
+
export const CHILD_METRICS_REVOKED = "gentle:runtime-metrics:revoked/v1";
|
|
10
|
+
const missing = { state: "unavailable" } as const;
|
|
11
|
+
const tokenFields = ["input", "output", "cacheRead", "cacheWrite", "reasoning", "totalTokens"] as const;
|
|
12
|
+
/** Recognize only names from this package's fixed assets and the transport's
|
|
13
|
+
* closed agent_class enum. Customized packaged agents retain their schema name;
|
|
14
|
+
* user-defined names never enter telemetry. Exact fingerprints preserve the
|
|
15
|
+
* worker/explore/verify compatibility mapping whose packaged names are prefixed.
|
|
16
|
+
* Model/thinking routing is excluded from fingerprints because installation
|
|
17
|
+
* rewrites it. No installed files are read; this class is not execution/review authority.
|
|
18
|
+
* Only the fixed package catalog is cached; runtime instructions are not retained.
|
|
19
|
+
*/
|
|
20
|
+
let definitions: Array<{ name: string; fingerprint: string; fingerprintClass?: AgentClass }> | undefined;
|
|
21
|
+
const packagedAgentClassAliases = new Map([["sdd-proposal", "sdd-propose"]] as const);
|
|
22
|
+
function fingerprintAgentClassName(name: string): string {
|
|
23
|
+
const compatibilityName = name.startsWith("gentle-ai-") ? name.slice("gentle-ai-".length) : name;
|
|
24
|
+
return packagedAgentClassAliases.get(compatibilityName) ?? compatibilityName;
|
|
25
|
+
}
|
|
26
|
+
function fingerprint(agent: AgentDefinition): string {
|
|
27
|
+
return JSON.stringify([agent.name, agent.description, agent.instructions, agent.tools, agent.mode]);
|
|
28
|
+
}
|
|
29
|
+
export function classifyBuiltinAgent(agent: AgentDefinition): AgentClass {
|
|
30
|
+
try {
|
|
31
|
+
definitions ??= readdirSync(new URL("../assets/agents/", import.meta.url))
|
|
32
|
+
.filter(file => file.endsWith(".md")).map(file => {
|
|
33
|
+
const path = new URL(`../assets/agents/${file}`, import.meta.url);
|
|
34
|
+
const parsed = parseAgentDefinition(readFileSync(path, "utf8"), path.pathname, "global");
|
|
35
|
+
if (!("instructions" in parsed)) throw new Error("Invalid packaged definition");
|
|
36
|
+
return { name: parsed.name, fingerprint: fingerprint(parsed),
|
|
37
|
+
fingerprintClass: parseAgentClass(fingerprintAgentClassName(parsed.name)) };
|
|
38
|
+
});
|
|
39
|
+
const namedClass = parseAgentClass(packagedAgentClassAliases.get(agent.name) ?? agent.name);
|
|
40
|
+
if (namedClass && definitions.some(entry => entry.name === agent.name)) return namedClass;
|
|
41
|
+
return definitions.find(entry => entry.fingerprintClass && entry.fingerprint === fingerprint(agent))?.fingerprintClass ?? UNKNOWN_AGENT_CLASS;
|
|
42
|
+
} catch { return UNKNOWN_AGENT_CLASS; }
|
|
43
|
+
}
|
|
44
|
+
function provider(value: unknown): FinalResponse["provider"] {
|
|
45
|
+
return normalizeRuntimeProvider(value);
|
|
46
|
+
}
|
|
47
|
+
function modelId(namespace: unknown, value: unknown): string {
|
|
48
|
+
if (value === "unknown" || value === undefined) return "unknown";
|
|
49
|
+
return classifyRuntimeModelId(namespace, value);
|
|
50
|
+
}
|
|
51
|
+
function effort(value: unknown): FinalResponse["effort"] {
|
|
52
|
+
return EFFORTS.includes(value as FinalResponse["effort"]) ? value as FinalResponse["effort"] : "unavailable";
|
|
53
|
+
}
|
|
54
|
+
export interface LaunchSelection {
|
|
55
|
+
agentClass: AgentClass;
|
|
56
|
+
selectedProvider: FinalResponse["provider"];
|
|
57
|
+
selectedModelId: string;
|
|
58
|
+
selectedEffort: FinalResponse["effort"];
|
|
59
|
+
}
|
|
60
|
+
export interface ChildLaunchBucket extends LaunchSelection {
|
|
61
|
+
evidence: "launch_configuration";
|
|
62
|
+
launches: number;
|
|
63
|
+
}
|
|
64
|
+
export function launchSelection(agent: AgentDefinition, model: ModelRef | undefined, thinking: unknown): LaunchSelection {
|
|
65
|
+
return { agentClass: classifyBuiltinAgent(agent), selectedProvider: provider(model?.provider),
|
|
66
|
+
selectedModelId: modelId(model?.provider, model?.id), selectedEffort: effort(thinking) };
|
|
67
|
+
}
|
|
68
|
+
/** LOCAL event only: these two bounded join IDs must never enter output/export. */
|
|
69
|
+
export interface ChildMetricsEvent {
|
|
70
|
+
schema: typeof CHILD_METRICS_EVENT;
|
|
71
|
+
parentSessionId: string;
|
|
72
|
+
taskId: string;
|
|
73
|
+
launchedAt: number;
|
|
74
|
+
launch: LaunchSelection;
|
|
75
|
+
status: TaskStatus;
|
|
76
|
+
agentSettled: boolean;
|
|
77
|
+
coverage: "final_assistant_messages_only";
|
|
78
|
+
droppedResponses: number;
|
|
79
|
+
responses: FinalResponse[];
|
|
80
|
+
}
|
|
81
|
+
export function childEvent(parentSessionId: string, taskId: string, launch: LaunchSelection, status: TaskStatus,
|
|
82
|
+
snapshot: ChildObservationSnapshot, launchedAt = 0): ChildMetricsEvent | undefined {
|
|
83
|
+
const result: ChildMetricsEvent = { schema: CHILD_METRICS_EVENT, parentSessionId, taskId, launchedAt, launch: { ...launch }, status,
|
|
84
|
+
agentSettled: snapshot.agentSettled, coverage: snapshot.coverage, droppedResponses: snapshot.droppedResponses,
|
|
85
|
+
responses: snapshot.responses.slice(0, 128).map((row, index) => {
|
|
86
|
+
const namespace = row.provider.state === "observed" ? row.provider.value : undefined;
|
|
87
|
+
const name = (value: typeof row.model) => value.state === "observed" ? modelId(namespace, value.value) : undefined;
|
|
88
|
+
return { kind: "final_assistant_response", responseId: String(index), agentClass: launch.agentClass,
|
|
89
|
+
executor: "worker", provider: provider(namespace), modelFamily: "unknown", observedModelId: name(row.model),
|
|
90
|
+
responseModelId: name(row.responseModel), providerThinkingLevel: effort(row.providerThinkingLevel.state === "observed" ? row.providerThinkingLevel.value : undefined),
|
|
91
|
+
selectedProvider: launch.selectedProvider, selectedModelId: launch.selectedModelId, effort: launch.selectedEffort,
|
|
92
|
+
error: row.stopReason === "error" ? "unknown" : row.stopReason === "aborted" ? "aborted" : "none",
|
|
93
|
+
tokens: Object.fromEntries(tokenFields.map(field => [field, { ...row.tokens[field] }])) as FinalResponse["tokens"],
|
|
94
|
+
responseHeadersMs: missing, fullResponseMs: missing };
|
|
95
|
+
}) };
|
|
96
|
+
return validEvent(result) ? result : undefined;
|
|
97
|
+
}
|
|
98
|
+
function validEvent(value: unknown): value is ChildMetricsEvent {
|
|
99
|
+
if (!value || typeof value !== "object") return false;
|
|
100
|
+
const e = value as ChildMetricsEvent;
|
|
101
|
+
const id = (value: unknown) => typeof value === "string" && value.length > 0 && value.length <= 128;
|
|
102
|
+
const l = e.launch;
|
|
103
|
+
return e.schema === CHILD_METRICS_EVENT && id(e.taskId) && id(e.parentSessionId)
|
|
104
|
+
&& Number.isFinite(e.launchedAt) && e.launchedAt >= 0
|
|
105
|
+
&& e.coverage === "final_assistant_messages_only" && typeof e.agentSettled === "boolean"
|
|
106
|
+
&& FINISHED_STATUSES.includes(e.status) && Number.isSafeInteger(e.droppedResponses) && e.droppedResponses >= 0
|
|
107
|
+
&& e.droppedResponses <= Number.MAX_SAFE_INTEGER && !!l && !!parseAgentClass(l.agentClass) && l.agentClass !== "orchestrator"
|
|
108
|
+
&& provider(l.selectedProvider) === l.selectedProvider && modelId(l.selectedProvider, l.selectedModelId) === l.selectedModelId
|
|
109
|
+
&& effort(l.selectedEffort) === l.selectedEffort && Array.isArray(e.responses) && e.responses.length <= 128
|
|
110
|
+
&& e.responses.every(row => validRuntimeResponse(row) && row.agentClass === l.agentClass
|
|
111
|
+
&& row.executor === "worker" && row.effort === l.selectedEffort && row.selectedProvider === l.selectedProvider && row.selectedModelId === l.selectedModelId
|
|
112
|
+
&& provider(row.provider) === row.provider && effort(row.providerThinkingLevel) === row.providerThinkingLevel
|
|
113
|
+
&& (row.observedModelId === undefined || modelId(row.provider, row.observedModelId) === row.observedModelId)
|
|
114
|
+
&& (row.responseModelId === undefined || modelId(row.provider, row.responseModelId) === row.responseModelId)
|
|
115
|
+
&& row.fullResponseMs.state === "unavailable" && row.responseHeadersMs.state === "unavailable");
|
|
116
|
+
}
|
|
117
|
+
/** Copy only validated local fields at the completion callback.
|
|
118
|
+
* Do not clone arbitrary event properties or retain a caller-mutable bus object.
|
|
119
|
+
*/
|
|
120
|
+
export function snapshotChildEvent(value: unknown): ChildMetricsEvent | undefined {
|
|
121
|
+
if (!validEvent(value)) return undefined;
|
|
122
|
+
const l = value.launch;
|
|
123
|
+
return { schema: CHILD_METRICS_EVENT, parentSessionId: value.parentSessionId, taskId: value.taskId, launchedAt: value.launchedAt,
|
|
124
|
+
launch: { agentClass: l.agentClass, selectedProvider: l.selectedProvider, selectedModelId: l.selectedModelId, selectedEffort: l.selectedEffort },
|
|
125
|
+
status: value.status, agentSettled: value.agentSettled, coverage: value.coverage, droppedResponses: value.droppedResponses,
|
|
126
|
+
responses: value.responses.map((row, index) => ({ kind: "final_assistant_response", responseId: String(index),
|
|
127
|
+
agentClass: row.agentClass, executor: "worker", provider: row.provider, observedModelId: row.observedModelId,
|
|
128
|
+
responseModelId: row.responseModelId, providerThinkingLevel: row.providerThinkingLevel, modelFamily: "unknown",
|
|
129
|
+
selectedProvider: l.selectedProvider, selectedModelId: l.selectedModelId, effort: l.selectedEffort, error: row.error,
|
|
130
|
+
tokens: Object.fromEntries(tokenFields.map(field => {
|
|
131
|
+
const t = row.tokens[field];
|
|
132
|
+
return [field, t?.state === "reported" ? { state: "reported", value: t.value } : { state: t?.state ?? "unavailable" }];
|
|
133
|
+
})) as FinalResponse["tokens"], responseHeadersMs: missing, fullResponseMs: missing })) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Export-facing shape: no IDs, paths, task labels, or raw agent/model names.
|
|
137
|
+
* Rankings are descending counts, NOT quality/success comparisons. Native
|
|
138
|
+
* transport is deliberately absent. Response buckets retain selected launch
|
|
139
|
+
* identity and effort separately from effective response evidence.
|
|
140
|
+
*/
|
|
141
|
+
export interface ChildCompositionSnapshot {
|
|
142
|
+
launches: ChildLaunchBucket[];
|
|
143
|
+
responses: RuntimeMetricBucket[];
|
|
144
|
+
settled: number;
|
|
145
|
+
statuses: Record<"completed" | "failed" | "cancelled" | "timed_out", number>;
|
|
146
|
+
droppedResponses: number;
|
|
147
|
+
saturated: boolean;
|
|
148
|
+
}
|
|
149
|
+
export class ChildComposition {
|
|
150
|
+
#seen = new Set<string>();
|
|
151
|
+
#pending = new WeakSet<object>();
|
|
152
|
+
#metrics = new RuntimeMetrics();
|
|
153
|
+
#launches = new Map<string, ChildLaunchBucket>();
|
|
154
|
+
#count = 0;
|
|
155
|
+
#settled = 0;
|
|
156
|
+
#statuses = { completed: 0, failed: 0, cancelled: 0, timed_out: 0 };
|
|
157
|
+
#dropped = 0;
|
|
158
|
+
#saturated = false;
|
|
159
|
+
reserve(value: unknown, session: string): value is ChildMetricsEvent {
|
|
160
|
+
if (!validEvent(value) || value.parentSessionId !== session || this.#seen.has(value.taskId)) return false;
|
|
161
|
+
if (this.#seen.size >= 256) { this.#saturated = true; return false; }
|
|
162
|
+
this.#seen.add(value.taskId);
|
|
163
|
+
this.#pending.add(value);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
record(event: ChildMetricsEvent): void {
|
|
167
|
+
if (!this.#pending.delete(event) || !validEvent(event)) return;
|
|
168
|
+
const key = JSON.stringify(event.launch);
|
|
169
|
+
let bucket = this.#launches.get(key);
|
|
170
|
+
if (!bucket && this.#launches.size < 64) {
|
|
171
|
+
bucket = { ...event.launch, evidence: "launch_configuration", launches: 0 };
|
|
172
|
+
this.#launches.set(key, bucket);
|
|
173
|
+
}
|
|
174
|
+
if (bucket) bucket.launches++;
|
|
175
|
+
else this.#saturated = true;
|
|
176
|
+
this.#settled += Number(event.agentSettled);
|
|
177
|
+
this.#statuses[event.status as keyof ChildCompositionSnapshot["statuses"]]++;
|
|
178
|
+
this.#dropped = Math.min(Number.MAX_SAFE_INTEGER, this.#dropped + event.droppedResponses);
|
|
179
|
+
for (const row of event.responses) {
|
|
180
|
+
if (this.#metrics.record({ ...row, responseId: String(this.#count + 1) }) === "recorded") this.#count++;
|
|
181
|
+
else { this.#dropped = Math.min(Number.MAX_SAFE_INTEGER, this.#dropped + 1); this.#saturated = true; }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
clear(): void {
|
|
185
|
+
this.#metrics = new RuntimeMetrics();
|
|
186
|
+
this.#pending = new WeakSet();
|
|
187
|
+
this.#launches.clear();
|
|
188
|
+
this.#count = this.#settled = this.#dropped = 0;
|
|
189
|
+
this.#statuses = { completed: 0, failed: 0, cancelled: 0, timed_out: 0 };
|
|
190
|
+
// Bounded tombstones survive revocation; denied tasks cannot replay.
|
|
191
|
+
}
|
|
192
|
+
snapshot(): ChildCompositionSnapshot {
|
|
193
|
+
return structuredClone({ launches: [...this.#launches.values()].sort((a, b) => b.launches - a.launches),
|
|
194
|
+
responses: this.#metrics.snapshot().sort((a, b) => b.responses - a.responses),
|
|
195
|
+
settled: this.#settled, statuses: this.#statuses, droppedResponses: this.#dropped, saturated: this.#saturated });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Shared across extension instances, including a cancelled process still exiting.
|
|
2
|
+
let busy = false;
|
|
3
|
+
|
|
4
|
+
/** One accepted attempt, never a queue. Defer resolver/process work beyond the
|
|
5
|
+
* provider callback. Busy events are discarded rather than captured for later.
|
|
6
|
+
* The transport must settle only after its process exits. Print-mode shutdown
|
|
7
|
+
* may join the one accepted attempt for a bounded deadline; it never queues or
|
|
8
|
+
* retries work. Every other disposal remains immediate.
|
|
9
|
+
*/
|
|
10
|
+
export class RuntimeMetricsAttempt {
|
|
11
|
+
#closed = false;
|
|
12
|
+
#scheduled?: ReturnType<typeof setImmediate>;
|
|
13
|
+
#abort?: AbortController;
|
|
14
|
+
#settled?: Promise<void>;
|
|
15
|
+
#release?: () => void;
|
|
16
|
+
offer(work: (signal: AbortSignal) => Promise<unknown>): boolean {
|
|
17
|
+
if (this.#closed || busy) return false;
|
|
18
|
+
busy = true;
|
|
19
|
+
const abort = new AbortController();
|
|
20
|
+
this.#abort = abort;
|
|
21
|
+
let release!: () => void;
|
|
22
|
+
const settled = new Promise<void>(resolve => { release = resolve; });
|
|
23
|
+
this.#settled = settled;
|
|
24
|
+
this.#release = release;
|
|
25
|
+
this.#scheduled = setImmediate(() => {
|
|
26
|
+
this.#scheduled = undefined;
|
|
27
|
+
void (async () => {
|
|
28
|
+
try { if (!abort.signal.aborted) await work(abort.signal); }
|
|
29
|
+
catch { /* An attempt is always best effort; never retry or report. */ }
|
|
30
|
+
finally {
|
|
31
|
+
this.#abort = undefined;
|
|
32
|
+
busy = false;
|
|
33
|
+
if (this.#settled === settled) {
|
|
34
|
+
this.#settled = undefined;
|
|
35
|
+
this.#release = undefined;
|
|
36
|
+
}
|
|
37
|
+
release();
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
});
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
/** Await only the already accepted attempt, up to the caller's deadline. */
|
|
44
|
+
async waitForSettled(timeoutMs: number): Promise<void> {
|
|
45
|
+
const settled = this.#settled;
|
|
46
|
+
if (!settled) return;
|
|
47
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
48
|
+
try {
|
|
49
|
+
await Promise.race([settled, new Promise<void>(resolve => { timer = setTimeout(resolve, timeoutMs); })]);
|
|
50
|
+
} finally {
|
|
51
|
+
if (timer) clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
dispose(): void {
|
|
55
|
+
if (this.#closed) return;
|
|
56
|
+
this.#closed = true;
|
|
57
|
+
if (this.#scheduled) {
|
|
58
|
+
clearImmediate(this.#scheduled);
|
|
59
|
+
this.#scheduled = undefined;
|
|
60
|
+
busy = false;
|
|
61
|
+
this.#settled = undefined;
|
|
62
|
+
this.#release?.();
|
|
63
|
+
this.#release = undefined;
|
|
64
|
+
}
|
|
65
|
+
this.#abort?.abort();
|
|
66
|
+
this.#abort = undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from "node:child_process";
|
|
2
|
+
import { resolveGentleAiBinary } from "./gentle-ai-binary.ts";
|
|
3
|
+
import { runtimeMetricsEnvAllows } from "./runtime-metrics-policy.ts";
|
|
4
|
+
import { isPublicRuntimeModelId, normalizeRuntimeModel, type RuntimeMetricBucket } from "./runtime-metrics.ts";
|
|
5
|
+
import type { ChildLaunchBucket } from "./runtime-metrics-children.ts";
|
|
6
|
+
|
|
7
|
+
import schema from "../contracts/telemetry/runtime-aggregate-v1.schema.json" with { type: "json" };
|
|
8
|
+
|
|
9
|
+
export type NativeRuntimeResult = "stored" | "duplicate" | "discarded" | "disabled";
|
|
10
|
+
export const NATIVE_SEND_ACK_SCHEMA = "gentle-ai.telemetry-runtime-send/v1";
|
|
11
|
+
const MAX_METRIC = schema.$defs.count.maximum;
|
|
12
|
+
const count = (value: number) => Number.isSafeInteger(value) && value >= 0 && value <= MAX_METRIC;
|
|
13
|
+
// Re-derives through the same schema-driven normalizer used at record time so
|
|
14
|
+
// an already-classified (provider, id) pair is re-validated against the
|
|
15
|
+
// transport's anyOf shape before it ever leaves the machine.
|
|
16
|
+
function publicModel(provider: string, id: string) {
|
|
17
|
+
return normalizeRuntimeModel(provider, id);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Closed field projection of one event, not a session snapshot. Oversized or
|
|
21
|
+
* invalid events discard whole; never split into queued sends or invent IDs.
|
|
22
|
+
*/
|
|
23
|
+
export function encodeNativeRuntimeEvent(source: readonly RuntimeMetricBucket[], launches: readonly ChildLaunchBucket[] = []): string | undefined {
|
|
24
|
+
if (!Array.isArray(source) || !Array.isArray(launches) || source.length < 1
|
|
25
|
+
|| source.length + launches.length > schema.properties.rows.maxItems) return undefined;
|
|
26
|
+
try {
|
|
27
|
+
const rows: Array<Record<string, unknown>> = source.map(row => {
|
|
28
|
+
if (!count(row.responses)) throw new Error("Invalid occurrence coverage");
|
|
29
|
+
const token = (field: keyof RuntimeMetricBucket["tokens"]) => {
|
|
30
|
+
const t = row.tokens[field];
|
|
31
|
+
const coverage = { reported: t.reported, unavailable: t.unavailable, unsupported: t.unsupported, sum: t.sum };
|
|
32
|
+
if (!Object.values(coverage).every(count) || (!coverage.reported && coverage.sum !== 0)) throw new Error("Invalid coverage");
|
|
33
|
+
return coverage;
|
|
34
|
+
};
|
|
35
|
+
const agentClass = schema.$defs.row.properties.agent_class.enum.includes(row.agentClass) ? row.agentClass : "unknown";
|
|
36
|
+
const effort = (value: string) => schema.$defs.effort.enum.includes(value) ? value : "unavailable";
|
|
37
|
+
// Evidence order: an actually-dispatched response model wins; otherwise
|
|
38
|
+
// the caller's own selection; otherwise the SDK-observed model the
|
|
39
|
+
// selection never captured (still labeled "selected" evidence, since
|
|
40
|
+
// the transport has no separate "observed" category). Never response
|
|
41
|
+
// evidence unless the response model itself normalizes to a public id.
|
|
42
|
+
const response = isPublicRuntimeModelId(row.responseModelId);
|
|
43
|
+
const selected = !response && isPublicRuntimeModelId(row.selectedModelId);
|
|
44
|
+
const observed = !response && !selected && isPublicRuntimeModelId(row.observedModelId);
|
|
45
|
+
const model = response ? publicModel(row.provider, row.responseModelId)
|
|
46
|
+
: selected ? publicModel(row.selectedProvider, row.selectedModelId)
|
|
47
|
+
: observed ? publicModel(row.provider, row.observedModelId)
|
|
48
|
+
: { provider: "unknown", id: "unknown" };
|
|
49
|
+
const time = row.fullResponseMs;
|
|
50
|
+
if (!count(time.measured) || !Number.isFinite(time.sum) || time.sum < 0 || time.sum > MAX_METRIC
|
|
51
|
+
|| (!time.measured && time.sum !== 0)) throw new Error("Invalid duration");
|
|
52
|
+
return { model, model_evidence: response ? "response" : (selected || observed) ? "selected" : "unknown",
|
|
53
|
+
agent_kind: agentClass === "orchestrator" || agentClass === "unknown" ? agentClass : "built_in", agent_class: agentClass,
|
|
54
|
+
selected_effort: effort(row.effort), effective_effort: effort(row.providerThinkingLevel),
|
|
55
|
+
launches: null, responses: row.responses,
|
|
56
|
+
input_tokens: token("input"), output_tokens: token("output"), cache_read_tokens: token("cacheRead"),
|
|
57
|
+
cache_creation_tokens: token("cacheWrite"), reasoning_tokens: token("reasoning"), total_tokens: token("totalTokens"),
|
|
58
|
+
error_category: row.error === "authentication" ? "auth" : ["none", "aborted", "rate_limit"].includes(row.error) ? row.error : "unknown",
|
|
59
|
+
duration: time.measured > 0 ? { kind: "request", measured_count: time.measured, sum_ms: time.sum }
|
|
60
|
+
: { kind: "unavailable", measured_count: 0, sum_ms: null } };
|
|
61
|
+
});
|
|
62
|
+
for (const launch of launches) {
|
|
63
|
+
if (!count(launch.launches) || launch.evidence !== "launch_configuration") return undefined;
|
|
64
|
+
const agentClass = schema.$defs.row.properties.agent_class.enum.includes(launch.agentClass) ? launch.agentClass : "unknown";
|
|
65
|
+
const empty = { reported: 0, unavailable: 0, unsupported: 0, sum: 0 };
|
|
66
|
+
rows.push({ model: publicModel(launch.selectedProvider, launch.selectedModelId), model_evidence: "selected",
|
|
67
|
+
agent_kind: agentClass === "orchestrator" || agentClass === "unknown" ? agentClass : "built_in", agent_class: agentClass,
|
|
68
|
+
selected_effort: schema.$defs.effort.enum.includes(launch.selectedEffort) ? launch.selectedEffort : "unavailable",
|
|
69
|
+
effective_effort: "unavailable", launches: launch.launches, responses: null,
|
|
70
|
+
input_tokens: empty, output_tokens: empty, cache_read_tokens: empty, cache_creation_tokens: empty,
|
|
71
|
+
reasoning_tokens: empty, total_tokens: empty, error_category: "unknown",
|
|
72
|
+
duration: { kind: "unavailable", measured_count: 0, sum_ms: null } });
|
|
73
|
+
}
|
|
74
|
+
const payload = JSON.stringify({ schema: schema.properties.schema.const, registry: schema.properties.registry.const, host: "pi", rows });
|
|
75
|
+
return Buffer.byteLength(payload) <= 16384 ? payload : undefined;
|
|
76
|
+
} catch { return undefined; }
|
|
77
|
+
}
|
|
78
|
+
export interface NativeRuntimeTransportDeps {
|
|
79
|
+
/** Observed launch occurrence accompanying this child completion, not totals. */
|
|
80
|
+
launches?: readonly ChildLaunchBucket[];
|
|
81
|
+
env?: NodeJS.ProcessEnv;
|
|
82
|
+
signal?: AbortSignal;
|
|
83
|
+
current?: () => boolean;
|
|
84
|
+
/** Injected test seams; production uses the verified pin or validated dev override. */
|
|
85
|
+
resolve?: () => string;
|
|
86
|
+
encode?: typeof encodeNativeRuntimeEvent;
|
|
87
|
+
spawn?: (file: string, args: string[], options: SpawnOptionsWithoutStdio) => ChildProcessWithoutNullStreams;
|
|
88
|
+
}
|
|
89
|
+
let busy = false;
|
|
90
|
+
|
|
91
|
+
/** Native owns fresh policy and exactly one POST. No policy/capability probe,
|
|
92
|
+
* disk intake, flush, queue or retry. Call via RuntimeMetricsAttempt so binary
|
|
93
|
+
* verification is outside the provider callback. Cancellation requests a kill;
|
|
94
|
+
* the slot stays occupied until close, never permitting overlapping processes.
|
|
95
|
+
*/
|
|
96
|
+
export async function sendNativeRuntimeEvent(rows: readonly RuntimeMetricBucket[], cwd: string,
|
|
97
|
+
deps: NativeRuntimeTransportDeps = {}): Promise<NativeRuntimeResult> {
|
|
98
|
+
const env = deps.env ?? process.env;
|
|
99
|
+
if (!runtimeMetricsEnvAllows(env)) return "disabled";
|
|
100
|
+
if (busy || deps.signal?.aborted || deps.current?.() === false) return "discarded";
|
|
101
|
+
busy = true;
|
|
102
|
+
try {
|
|
103
|
+
const payload = (deps.encode ?? encodeNativeRuntimeEvent)(rows, deps.launches);
|
|
104
|
+
if (!payload || Buffer.byteLength(payload) > 16384) return "discarded";
|
|
105
|
+
const file = (deps.resolve ?? resolveGentleAiBinary)();
|
|
106
|
+
if (!runtimeMetricsEnvAllows(env)) return "disabled";
|
|
107
|
+
if (deps.signal?.aborted || deps.current?.() === false) return "discarded";
|
|
108
|
+
return await sendProcess(file, cwd, payload, env, deps.spawn ?? spawn, deps.signal);
|
|
109
|
+
} catch { return "discarded"; }
|
|
110
|
+
finally { busy = false; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sendProcess(file: string, cwd: string, payload: string, env: NodeJS.ProcessEnv,
|
|
114
|
+
launch: NonNullable<NativeRuntimeTransportDeps["spawn"]>, signal?: AbortSignal): Promise<NativeRuntimeResult> {
|
|
115
|
+
return new Promise(done => {
|
|
116
|
+
let child: ChildProcessWithoutNullStreams | undefined;
|
|
117
|
+
let output = "";
|
|
118
|
+
let rejected = false;
|
|
119
|
+
let settled = false;
|
|
120
|
+
const ignore = () => {};
|
|
121
|
+
const finish = (result: NativeRuntimeResult) => {
|
|
122
|
+
if (settled) return;
|
|
123
|
+
settled = true;
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
signal?.removeEventListener("abort", cancel);
|
|
126
|
+
output = "";
|
|
127
|
+
if (child) {
|
|
128
|
+
for (const stream of [child.stdin, child.stdout, child.stderr]) {
|
|
129
|
+
stream.on("error", ignore);
|
|
130
|
+
try { stream.destroy(); } catch { /* Best effort cleanup. */ }
|
|
131
|
+
}
|
|
132
|
+
try { child.unref(); } catch { /* No diagnostics. */ }
|
|
133
|
+
}
|
|
134
|
+
done(result);
|
|
135
|
+
};
|
|
136
|
+
const cancel = () => {
|
|
137
|
+
if (rejected || settled) return;
|
|
138
|
+
rejected = true;
|
|
139
|
+
output = "";
|
|
140
|
+
try { child?.kill("SIGKILL"); } catch { /* Keep busy until close. */ }
|
|
141
|
+
};
|
|
142
|
+
const timer = setTimeout(cancel, 1000);
|
|
143
|
+
timer.unref();
|
|
144
|
+
try {
|
|
145
|
+
child = launch(file, ["telemetry", "runtime", "send", "--json"],
|
|
146
|
+
{ cwd, env, shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
147
|
+
child.on("error", cancel);
|
|
148
|
+
for (const stream of [child.stdin, child.stdout, child.stderr]) stream.on("error", cancel);
|
|
149
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
150
|
+
if (rejected || settled) return;
|
|
151
|
+
if (Buffer.byteLength(output) + chunk.length > 2048) { cancel(); return; }
|
|
152
|
+
output += chunk.toString("utf8");
|
|
153
|
+
});
|
|
154
|
+
child.stderr.on("data", cancel); // Do not retain or expose raw errors.
|
|
155
|
+
child.on("close", (code: number | null, exitSignal: NodeJS.Signals | null) => {
|
|
156
|
+
let result: NativeRuntimeResult = "discarded";
|
|
157
|
+
if (!rejected && code === 0 && exitSignal === null) {
|
|
158
|
+
try {
|
|
159
|
+
const value = JSON.parse(output);
|
|
160
|
+
if (Object.keys(value).sort().join(",") === "decision,schema" && value.schema === NATIVE_SEND_ACK_SCHEMA
|
|
161
|
+
&& ["stored", "duplicate", "discarded", "disabled"].includes(value.decision)) result = value.decision;
|
|
162
|
+
} catch { /* Invalid/old acknowledgements discard. */ }
|
|
163
|
+
}
|
|
164
|
+
finish(result);
|
|
165
|
+
});
|
|
166
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
167
|
+
if (signal?.aborted) cancel();
|
|
168
|
+
if (!rejected) child.stdin.end(payload, "utf8");
|
|
169
|
+
} catch {
|
|
170
|
+
if (child) cancel();
|
|
171
|
+
else finish("discarded");
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { gentleAiDevBinaryOverrideConfigured, resolveGentleAiBinary } from "./gentle-ai-binary.ts";
|
|
2
|
+
import { createNodeExecFileAdapter, type ExecFileAdapter } from "./native-review-cli.ts";
|
|
3
|
+
|
|
4
|
+
export interface RuntimeMetricsPolicyDeps { resolve?: () => string; exec?: ExecFileAdapter }
|
|
5
|
+
|
|
6
|
+
export function runtimeMetricsEnvAllows(env: NodeJS.ProcessEnv): boolean {
|
|
7
|
+
// Unknown nonempty spellings veto too: never weaken a native environment veto.
|
|
8
|
+
const truthy = (value: string | undefined) => !["", "0", "false", "no", "off"].includes(value?.trim().toLowerCase() ?? "");
|
|
9
|
+
return !truthy(env.DO_NOT_TRACK) && !truthy(env.CI) && !truthy(env.GITHUB_ACTIONS)
|
|
10
|
+
&& env.GENTLE_AI_TELEMETRY?.trim() !== "0";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function publishedBinary(): string {
|
|
14
|
+
// This feature must not select an unpublished development binary or install one.
|
|
15
|
+
if (gentleAiDevBinaryOverrideConfigured()) throw new Error("Policy binary unavailable");
|
|
16
|
+
return resolveGentleAiBinary();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Only a validated boolean crosses this boundary; never raw stdout or errors.
|
|
20
|
+
* The shared adapter and outer timer bound asynchronous process waiting, NOT
|
|
21
|
+
* end-to-end time: the supported resolver synchronously reads/hashes the binary
|
|
22
|
+
* on every check. It exposes no supported validation cache. Keep verification
|
|
23
|
+
* intact rather than cache an unchecked path or invent stat-based trust.
|
|
24
|
+
* No retries, status/preview fallback, enrollment, installation or polling.
|
|
25
|
+
*/
|
|
26
|
+
export async function readRuntimeMetricsPolicy(cwd: string,
|
|
27
|
+
{ resolve = publishedBinary, exec = createNodeExecFileAdapter() }: RuntimeMetricsPolicyDeps = {}): Promise<boolean> {
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
30
|
+
try {
|
|
31
|
+
const deadline = new Promise<false>(done => {
|
|
32
|
+
timer = setTimeout(() => { controller.abort(); done(false); }, 1000);
|
|
33
|
+
});
|
|
34
|
+
const probe = async () => {
|
|
35
|
+
const result = await exec({ file: resolve(), arguments: ["telemetry", "policy", "--json"],
|
|
36
|
+
cwd, timeoutMs: 1000, maxBufferBytes: 2048, signal: controller.signal });
|
|
37
|
+
if (result.exitCode !== 0 || result.signal !== null || result.timedOut !== false
|
|
38
|
+
|| result.outputLimitExceeded !== false || result.stderr !== ""
|
|
39
|
+
|| typeof result.stdout !== "string" || Buffer.byteLength(result.stdout) > 2048) return false;
|
|
40
|
+
const value: unknown = JSON.parse(result.stdout);
|
|
41
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
42
|
+
const row = value as Record<string, unknown>;
|
|
43
|
+
return Object.keys(row).sort().join(",") === "enabled,operation,reason,schema,source"
|
|
44
|
+
&& row.schema === "gentle-ai.telemetry-policy/v1" && row.operation === "policy"
|
|
45
|
+
&& typeof row.enabled === "boolean" && row.enabled && row.reason === "enabled"
|
|
46
|
+
&& ["DO_NOT_TRACK", "GENTLE_AI_TELEMETRY", "CI", "state", "default"].includes(row.source as string);
|
|
47
|
+
};
|
|
48
|
+
return await Promise.race([probe(), deadline]);
|
|
49
|
+
} catch { return false; }
|
|
50
|
+
finally { clearTimeout(timer); }
|
|
51
|
+
}
|